增加前端格式检查配置

This commit is contained in:
yml2213
2026-06-08 06:40:33 +08:00
parent 79ea3a476c
commit 500f511185
150 changed files with 7254 additions and 4013 deletions
@@ -1,7 +1,17 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, Delete, Document, Edit, Plus, QuestionFilled, Refresh, Top, Warning } from '@element-plus/icons-vue'
import {
Bell,
Delete,
Document,
Edit,
Plus,
QuestionFilled,
Refresh,
Top,
Warning,
} from '@element-plus/icons-vue'
import {
fetchAdminAnnouncements,
createAnnouncement,
@@ -199,12 +209,34 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</div>
<div class="filter-bar">
<el-select v-model="statusFilter" placeholder="筛选状态" clearable @change="handleFilterChange" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-select
v-model="statusFilter"
placeholder="筛选状态"
clearable
@change="handleFilterChange"
style="width: 150px"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="categoryFilter" placeholder="筛选分类" clearable @change="handleFilterChange" style="width: 150px">
<el-select
v-model="categoryFilter"
placeholder="筛选分类"
clearable
@change="handleFilterChange"
style="width: 150px"
>
<el-option value="" label="全部分类" />
<el-option v-for="cat in categories" :key="cat.value" :label="cat.label" :value="cat.value" />
<el-option
v-for="cat in categories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
/>
</el-select>
</div>
@@ -220,14 +252,23 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
<el-icon><Top /></el-icon>
置顶
</el-tag>
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain">重要</el-tag>
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain"
>重要</el-tag
>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag :style="{ borderColor: getCategoryColor(row.category), color: getCategoryColor(row.category) }" effect="plain" size="small">
<el-tag
:style="{
borderColor: getCategoryColor(row.category),
color: getCategoryColor(row.category),
}"
effect="plain"
size="small"
>
{{ getCategoryLabel(row.category) }}
</el-tag>
</template>
@@ -248,17 +289,43 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)" text style="color: #1d6fd6; font-weight: 700;">
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
text
style="color: #1d6fd6; font-weight: 700"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button v-if="row.status === 'draft'" type="success" size="small" @click="handlePublish(row)" text style="color: #4caf50; font-weight: 700;">
<el-button
v-if="row.status === 'draft'"
type="success"
size="small"
@click="handlePublish(row)"
text
style="color: #4caf50; font-weight: 700"
>
发布
</el-button>
<el-button v-if="row.status === 'published'" type="warning" size="small" @click="handleArchive(row)" text style="color: #d97706; font-weight: 700;">
<el-button
v-if="row.status === 'published'"
type="warning"
size="small"
@click="handleArchive(row)"
text
style="color: #d97706; font-weight: 700"
>
归档
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)" text style="color: #dc2626; font-weight: 700;">
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
text
style="color: #dc2626; font-weight: 700"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
@@ -18,7 +18,11 @@ const filters = reactive({
biz_type: '',
})
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
const highRiskCount = computed(
() =>
logs.value.filter(item => item.action.includes('freeze') || item.action.includes('update'))
.length
)
onMounted(loadLogs)
@@ -81,7 +85,9 @@ function actionType(action: string) {
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs"
>查询</el-button
>
</div>
</div>
@@ -101,7 +107,13 @@ function actionType(action: string) {
<el-input v-model="filters.actor_id" clearable placeholder="按管理员筛选" />
</el-form-item>
<el-form-item label="动作">
<el-select v-model="filters.action" clearable filterable placeholder="全部动作" class="full-control">
<el-select
v-model="filters.action"
clearable
filterable
placeholder="全部动作"
class="full-control"
>
<el-option label="冻结用户" value="admin_user.freeze" />
<el-option label="解冻用户" value="admin_user.unfreeze" />
<el-option label="后台下架商品" value="listing.admin_offline" />
@@ -155,9 +167,18 @@ function actionType(action: string) {
@page-change="handlePageChange"
/>
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
<el-dialog
:model-value="!!activeLog"
title="审计明细"
width="720px"
@update:model-value="activeLog = null"
>
<div v-if="activeLog" class="dialog-body">
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
<p>
<strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{
activeLog.biz_id || '-'
}}
</p>
<p>操作人{{ actorName(activeLog) }} · IP{{ activeLog.ip }}</p>
<p>User-Agent{{ activeLog.user_agent }}</p>
<div class="code-panel">
@@ -13,7 +13,7 @@ import {
Tickets,
User,
Wallet,
Warning
Warning,
} from '@element-plus/icons-vue'
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
@@ -23,7 +23,12 @@ import { formatDateTime } from '@/utils/time'
const money = useMoney()
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
const {
loading,
error,
data: dashboard,
load: loadDashboard,
} = useAdminTable<AdminDashboard>({
fetchFn: fetchAdminDashboard,
})
</script>
@@ -108,7 +113,13 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
待处理事项
</h2>
<el-tag type="warning" effect="dark" round>
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }}
{{
dashboard.pending.disputes +
dashboard.pending.listing_reviews +
dashboard.pending.pending_handoffs +
dashboard.pending.pending_return_confirms
}}
</el-tag>
</div>
<div class="pending-list">
@@ -117,28 +128,36 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<span class="pending-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{ dashboard.pending.disputes }}</strong>
<strong :class="{ 'has-pending': dashboard.pending.disputes > 0 }">{{
dashboard.pending.disputes
}}</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/listings/review">
<div class="pending-info">
<span class="pending-label">待审核商品</span>
<span class="pending-desc">新提交的商品审核</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{ dashboard.pending.listing_reviews }}</strong>
<strong :class="{ 'has-pending': dashboard.pending.listing_reviews > 0 }">{{
dashboard.pending.listing_reviews
}}</strong>
</RouterLink>
<div class="pending-row">
<div class="pending-info">
<span class="pending-label">待交接订单</span>
<span class="pending-desc">等待卖家交接</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{ dashboard.pending.pending_handoffs }}</strong>
<strong :class="{ 'has-pending': dashboard.pending.pending_handoffs > 0 }">{{
dashboard.pending.pending_handoffs
}}</strong>
</div>
<div class="pending-row">
<div class="pending-info">
<span class="pending-label">待结账确认</span>
<span class="pending-desc">等待双方确认</span>
</div>
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{ dashboard.pending.pending_return_confirms }}</strong>
<strong :class="{ 'has-pending': dashboard.pending.pending_return_confirms > 0 }">{{
dashboard.pending.pending_return_confirms
}}</strong>
</div>
</div>
</div>
@@ -190,7 +209,17 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<el-table-column prop="title" label="商品名称" min-width="170" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'renting' ? 'primary' : 'warning'" size="small" effect="plain">
<el-tag
:type="
row.status === 'completed'
? 'success'
: row.status === 'renting'
? 'primary'
: 'warning'
"
size="small"
effect="plain"
>
{{ orderStatusLabel(row.status) }}
</el-tag>
</template>
@@ -218,7 +247,11 @@ const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<A
<el-table-column prop="type" label="申诉类型" width="140" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small" effect="plain">
<el-tag
:type="row.status === 'resolved' ? 'success' : 'danger'"
size="small"
effect="plain"
>
{{ disputeStatusLabel(row.status) }}
</el-tag>
</template>
@@ -128,11 +128,26 @@ function readError(error: unknown, fallback: string) {
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
<el-table-column
prop="arbitration_result"
label="仲裁结果"
width="150"
show-overflow-tooltip
/>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
<el-button
size="small"
:disabled="evidenceItems(row).length === 0"
@click="evidenceDispute = row"
>证据</el-button
>
<el-button
size="small"
:disabled="row.status === 'resolved'"
@click="openArbitration(row)"
>仲裁</el-button
>
</template>
</el-table-column>
</el-table>
@@ -146,9 +161,16 @@ function readError(error: unknown, fallback: string) {
@page-change="handlePageChange"
/>
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
<el-dialog
:model-value="!!activeDispute"
title="申诉仲裁"
width="560px"
@update:model-value="activeDispute = null"
>
<div v-if="activeDispute" class="dialog-body">
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
<p>
<strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}
</p>
<p>{{ activeDispute.description }}</p>
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
<el-option label="全额退款" value="full_refund" />
@@ -168,19 +190,38 @@ function readError(error: unknown, fallback: string) {
:step="10"
placeholder="裁决金额"
/>
<p v-if="result === 'partial_refund'">部分退款金额表示退给租客的金额剩余冻结金额结算给号主</p>
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">金额表示从押金中赔付给号主的部分不填则默认处理全额押金</p>
<el-input v-model="remark" class="panel-action" type="textarea" :rows="4" placeholder="填写客服裁决说明" />
<p v-if="result === 'partial_refund'">
部分退款金额表示退给租客的金额剩余冻结金额结算给号主
</p>
<p v-if="['deduct_deposit', 'compensate_owner'].includes(result)">
金额表示从押金中赔付给号主的部分不填则默认处理全额押金
</p>
<el-input
v-model="remark"
class="panel-action"
type="textarea"
:rows="4"
placeholder="填写客服裁决说明"
/>
</div>
<template #footer>
<el-button @click="activeDispute = null">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleArbitrate">保存裁决</el-button>
<el-button type="primary" :loading="submitting" @click="handleArbitrate"
>保存裁决</el-button
>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceDispute" title="申诉证据" width="640px" @update:model-value="evidenceDispute = null">
<el-dialog
:model-value="!!evidenceDispute"
title="申诉证据"
width="640px"
@update:model-value="evidenceDispute = null"
>
<div v-if="evidenceDispute" class="dialog-body">
<p><strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}</p>
<p>
<strong>{{ evidenceDispute.order_no }}</strong> · {{ evidenceDispute.title }}
</p>
<div v-for="item in evidenceItems(evidenceDispute)" :key="item" class="evidence-row">
<span>{{ item }}</span>
<el-button size="small" @click="openEvidence(item)">打开</el-button>
@@ -4,7 +4,12 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/features/listings'
import {
adminMarkListingAbnormal,
adminOfflineListing,
fetchAdminListing,
type Listing,
} from '@/features/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -15,8 +20,15 @@ const listing = ref<Listing | null>(null)
const actionType = ref<'offline' | 'abnormal' | ''>('')
const reason = ref('')
const actionTitle = computed(() => (actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'))
const canOperate = computed(() => !!listing.value && listing.value.status !== 'rented' && !['offline', 'abnormal'].includes(listing.value.status))
const actionTitle = computed(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
)
const canOperate = computed(
() =>
!!listing.value &&
listing.value.status !== 'rented' &&
!['offline', 'abnormal'].includes(listing.value.status)
)
onMounted(loadListing)
@@ -100,8 +112,12 @@ function readError(error: unknown, fallback: string) {
<RouterLink to="/admin/listings">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')">强制下架</el-button>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
>强制下架</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
</div>
</div>
@@ -164,10 +180,22 @@ function readError(error: unknown, fallback: string) {
<p v-else>暂无截图</p>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="listing" class="dialog-body">
<p><strong>{{ listing.title }}</strong></p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写后台操作原因,会写入审计日志并通知号主" />
<p>
<strong>{{ listing.title }}</strong>
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写后台操作原因,会写入审计日志并通知号主"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
@@ -4,7 +4,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { adjustListingReviewPrice, approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
import {
adjustListingReviewPrice,
approveListing,
fetchPendingReviewListings,
rejectListing,
type Listing,
} from '@/features/listings'
import {
assetRegions,
formatHafCoinM,
@@ -26,7 +32,13 @@ interface RiskItem {
}
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
]
const loading = ref(false)
const submitting = ref(false)
@@ -50,10 +62,12 @@ const filters = reactive({
const previewURLs = reactive<Record<string, string>>({})
const createdObjectURLs = new Set<string>()
const selectedListing = computed(() => listings.value.find((item) => item.id === selectedID.value) || listings.value[0] || null)
const selectedListing = computed(
() => listings.value.find(item => item.id === selectedID.value) || listings.value[0] || null
)
const filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return listings.value.filter((item) => {
return listings.value.filter(item => {
if (filters.risk === 'external' && !isExternalUpload(item)) return false
if (filters.risk === 'defaultImage' && !hasDefaultScreenshot(item)) return false
if (filters.risk === 'ban' && !hasBanRecord(item)) return false
@@ -62,26 +76,33 @@ const filteredListings = computed(() => {
})
})
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null))
const selectedResources = computed(() =>
selectedListing.value ? getListingResources(selectedListing.value) : []
)
const selectedSkins = computed(() =>
selectedListing.value ? getSkinNames(selectedListing.value) : []
)
const priceAdjustPreview = computed(() =>
priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null
)
watch(
() => selectedListing.value,
async (listing) => {
async listing => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true },
{ immediate: true }
)
watch(priceAdjustMode, (mode, previousMode) => {
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
if (mode === 'price') {
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
priceAdjustForm.buyer_total_price =
preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
} else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
}
@@ -89,14 +110,14 @@ watch(priceAdjustMode, (mode, previousMode) => {
onMounted(loadListings)
onBeforeUnmount(() => {
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
createdObjectURLs.forEach(url => URL.revokeObjectURL(url))
})
async function loadListings() {
loading.value = true
try {
listings.value = await fetchPendingReviewListings()
if (!selectedID.value || !listings.value.some((item) => item.id === selectedID.value)) {
if (!selectedID.value || !listings.value.some(item => item.id === selectedID.value)) {
selectedID.value = listings.value[0]?.id || null
}
} finally {
@@ -109,7 +130,7 @@ function selectListing(row: Listing) {
}
function replaceListing(next: Listing) {
const index = listings.value.findIndex((item) => item.id === next.id)
const index = listings.value.findIndex(item => item.id === next.id)
if (index >= 0) {
listings.value.splice(index, 1, next)
} else {
@@ -157,8 +178,14 @@ async function handleSavePriceAdjust() {
if (!priceAdjustListing.value) return
const payload =
priceAdjustMode.value === 'ratio'
? { buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0), reason: priceAdjustForm.reason.trim() }
: { buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0), reason: priceAdjustForm.reason.trim() }
? {
buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0),
reason: priceAdjustForm.reason.trim(),
}
: {
buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0),
reason: priceAdjustForm.reason.trim(),
}
if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格')
return
@@ -233,7 +260,9 @@ function assetNumberText(row: Listing, key: string) {
function priceBreakdown(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown
return typeof breakdown === 'object' && breakdown !== null ? (breakdown as Record<string, unknown>) : {}
return typeof breakdown === 'object' && breakdown !== null
? (breakdown as Record<string, unknown>)
: {}
}
function breakdownNumber(row: Listing, key: string) {
@@ -278,7 +307,8 @@ function sellerCoinBasePrice(row: Listing) {
}
function sellerRatio(row: Listing) {
const value = breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
const value =
breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
if (value > 0) return value
const base = sellerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
@@ -302,7 +332,10 @@ function buyerRatio(row: Listing) {
return base > 0 ? getCoinWan(row) / base : 0
}
function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = priceAdjustMode.value) {
function calculatePriceAdjustPreview(
row: Listing,
mode: 'ratio' | 'price' = priceAdjustMode.value
) {
const consumablePrice = getListingConsumablePrice(row)
const coinWan = getCoinWan(row)
if (mode === 'price') {
@@ -313,7 +346,8 @@ function calculatePriceAdjustPreview(row: Listing, mode: 'ratio' | 'price' = pri
}
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
const buyerTotalPrice = buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerTotalPrice =
buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
@@ -412,7 +446,7 @@ function isExternalUpload(row: Listing) {
}
function hasDefaultScreenshot(row: Listing) {
return row.screenshot_urls?.some((url) => isDefaultScreenshot(url)) || false
return row.screenshot_urls?.some(url => isDefaultScreenshot(url)) || false
}
function isDefaultScreenshot(url: string) {
@@ -425,12 +459,18 @@ function riskItems(row: Listing): RiskItem[] {
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
if (getListingConsumablePrice(row) > 0 && Number(row.deposit_amount || 0) <= getListingConsumablePrice(row)) {
if (
getListingConsumablePrice(row) > 0 &&
Number(row.deposit_amount || 0) <= getListingConsumablePrice(row)
) {
items.push({ label: '押金不高于消耗品价值', level: 'danger' })
}
if (readAssetNumber(row, 'daily_loss_m') <= 0) items.push({ label: '缺少每日损耗', level: 'warning' })
if (readAssetNumber(row, 'fire_level') <= 40) items.push({ label: '烽火等级接近下限', level: 'warning' })
if (!readAssetString(row, 'season_insurance')) items.push({ label: '缺少保险格数', level: 'warning' })
if (readAssetNumber(row, 'daily_loss_m') <= 0)
items.push({ label: '缺少每日损耗', level: 'warning' })
if (readAssetNumber(row, 'fire_level') <= 40)
items.push({ label: '烽火等级接近下限', level: 'warning' })
if (!readAssetString(row, 'season_insurance'))
items.push({ label: '缺少保险格数', level: 'warning' })
if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
return items
}
@@ -525,19 +565,35 @@ function readError(error: unknown, fallback: string) {
</div>
<div class="review-summary">
<button class="summary-card" :class="{ active: filters.risk === 'all' }" @click="filters.risk = 'all'">
<button
class="summary-card"
:class="{ active: filters.risk === 'all' }"
@click="filters.risk = 'all'"
>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'external' }" @click="filters.risk = 'external'">
<button
class="summary-card"
:class="{ active: filters.risk === 'external' }"
@click="filters.risk = 'external'"
>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'defaultImage' }" @click="filters.risk = 'defaultImage'">
<button
class="summary-card"
:class="{ active: filters.risk === 'defaultImage' }"
@click="filters.risk = 'defaultImage'"
>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</button>
<button class="summary-card" :class="{ active: filters.risk === 'ban' }" @click="filters.risk = 'ban'">
<button
class="summary-card"
:class="{ active: filters.risk === 'ban' }"
@click="filters.risk = 'ban'"
>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</button>
@@ -546,7 +602,12 @@ function readError(error: unknown, fallback: string) {
<div class="review-workbench">
<aside class="review-queue">
<div class="queue-toolbar">
<el-input v-model="filters.keyword" :prefix-icon="Search" clearable placeholder="搜索标题、客服、段位、皮肤" />
<el-input
v-model="filters.keyword"
:prefix-icon="Search"
clearable
placeholder="搜索标题、客服、段位、皮肤"
/>
<el-select v-model="filters.risk" placeholder="风险筛选">
<el-option label="全部待审" value="all" />
<el-option label="外部上传" value="external" />
@@ -571,16 +632,23 @@ function readError(error: unknown, fallback: string) {
<div class="queue-meta">
<span>{{ item.rank_level || '-' }}</span>
<span>{{ assetText(item, 'season_insurance') }}</span>
<span>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span>
<span
>{{ assetText(item, 'stamina_level') }}/{{ assetText(item, 'load_level') }}</span
>
<span>KD {{ assetNumberText(item, 'secret_kd') }}</span>
</div>
<div class="queue-footer">
<span>{{ money(item.price) }} / {{ money(item.deposit_amount) }}</span>
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{ uploaderName(item) }}</el-tag>
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
uploaderName(item)
}}</el-tag>
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
</div>
</button>
<el-empty v-if="!loading && !filteredListings.length" description="暂无符合条件的待审核商品" />
<el-empty
v-if="!loading && !filteredListings.length"
description="暂无符合条件的待审核商品"
/>
</div>
</aside>
@@ -595,12 +663,29 @@ function readError(error: unknown, fallback: string) {
<RouterLink :to="`/admin/listings/${selectedListing.id}`">
<el-button>详情页</el-button>
</RouterLink>
<el-button :icon="Close" type="danger" :loading="submitting" @click="openReject(selectedListing)">拒绝</el-button>
<el-button :icon="Check" type="primary" :loading="submitting" @click="handleApprove(selectedListing)">通过</el-button>
<el-button
:icon="Close"
type="danger"
:loading="submitting"
@click="openReject(selectedListing)"
>拒绝</el-button
>
<el-button
:icon="Check"
type="primary"
:loading="submitting"
@click="handleApprove(selectedListing)"
>通过</el-button
>
</div>
</div>
<div class="risk-strip">
<el-tag v-for="risk in activeRisks" :key="risk.label" :type="riskTagType(risk.level)" effect="light">
<el-tag
v-for="risk in activeRisks"
:key="risk.label"
:type="riskTagType(risk.level)"
effect="light"
>
{{ risk.label }}
</el-tag>
</div>
@@ -612,14 +697,19 @@ function readError(error: unknown, fallback: string) {
<h3>价格审核</h3>
<p>核对卖家提交价格平台加价规则和最终买家展示价</p>
</div>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)"
>调整价格</el-button
>
</div>
<div class="price-decision-grid">
<div class="price-decision-card seller">
<span>卖家发布</span>
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small>
<small
>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品
{{ money(getListingConsumablePrice(selectedListing)) }}</small
>
</div>
<div class="price-decision-card rule">
<span>当前加价规则</span>
@@ -648,29 +738,82 @@ function readError(error: unknown, fallback: string) {
<h3>账号属性</h3>
</div>
<dl class="detail-list">
<div><dt>上号方式</dt><dd>{{ selectedListing.login_platform || '-' }}</dd></div>
<div><dt>区服</dt><dd>{{ selectedListing.server_region || '-' }}</dd></div>
<div><dt>段位</dt><dd>{{ selectedListing.rank_level || '-' }}</dd></div>
<div><dt>烽火等级</dt><dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd></div>
<div><dt>保险格数</dt><dd>{{ assetText(selectedListing, 'season_insurance') }}</dd></div>
<div><dt>绝密KD</dt><dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd></div>
<div><dt>体力/负重</dt><dd>{{ assetText(selectedListing, 'stamina_level') }} / {{ assetText(selectedListing, 'load_level') }}</dd></div>
<div><dt>封禁记录</dt><dd>{{ banRecordText(selectedListing) }}</dd></div>
<div>
<dt>上号方式</dt>
<dd>{{ selectedListing.login_platform || '-' }}</dd>
</div>
<div>
<dt>区服</dt>
<dd>{{ selectedListing.server_region || '-' }}</dd>
</div>
<div>
<dt>段位</dt>
<dd>{{ selectedListing.rank_level || '-' }}</dd>
</div>
<div>
<dt>烽火等级</dt>
<dd>{{ assetNumberText(selectedListing, 'fire_level') }}</dd>
</div>
<div>
<dt>保险格数</dt>
<dd>{{ assetText(selectedListing, 'season_insurance') }}</dd>
</div>
<div>
<dt>绝密KD</dt>
<dd>{{ assetNumberText(selectedListing, 'secret_kd') }}</dd>
</div>
<div>
<dt>体力/负重</dt>
<dd>
{{ assetText(selectedListing, 'stamina_level') }} /
{{ assetText(selectedListing, 'load_level') }}
</dd>
</div>
<div>
<dt>封禁记录</dt>
<dd>{{ banRecordText(selectedListing) }}</dd>
</div>
</dl>
</div>
<div class="review-panel">
<div class="panel-title">
<h3>上传信息</h3>
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info">外部上传</el-tag>
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
>外部上传</el-tag
>
</div>
<dl class="detail-list">
<div><dt>上传人</dt><dd>{{ uploaderName(selectedListing) }}</dd></div>
<div><dt>号主</dt><dd>{{ selectedListing.owner_phone || selectedListing.owner_nickname || selectedListing.owner_id }}</dd></div>
<div><dt>联系电话</dt><dd>{{ contactPhone(selectedListing) }}</dd></div>
<div><dt>常用地区</dt><dd>{{ commonRegionText(selectedListing) }}</dd></div>
<div><dt>在线时间</dt><dd>{{ ownerOnlineText(selectedListing) }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDateTime(selectedListing.updated_at) }}</dd></div>
<div>
<dt>上传人</dt>
<dd>{{ uploaderName(selectedListing) }}</dd>
</div>
<div>
<dt>号主</dt>
<dd>
{{
selectedListing.owner_phone ||
selectedListing.owner_nickname ||
selectedListing.owner_id
}}
</dd>
</div>
<div>
<dt>联系电话</dt>
<dd>{{ contactPhone(selectedListing) }}</dd>
</div>
<div>
<dt>常用地区</dt>
<dd>{{ commonRegionText(selectedListing) }}</dd>
</div>
<div>
<dt>在线时间</dt>
<dd>{{ ownerOnlineText(selectedListing) }}</dd>
</div>
<div>
<dt>提交时间</dt>
<dd>{{ formatDateTime(selectedListing.updated_at) }}</dd>
</div>
</dl>
</div>
</section>
@@ -681,9 +824,16 @@ function readError(error: unknown, fallback: string) {
<span>额外消耗品约 {{ money(getListingConsumablePrice(selectedListing)) }}</span>
</div>
<div class="inventory-grid">
<div><span>AWM子弹</span><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong></div>
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong></div>
<div><span>6</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong></div>
<div>
<span>AWM子弹</span
><strong>{{ getResourceQuantity(selectedListing, 'awmAmmo') }}</strong>
</div>
<div>
<span>6</span><strong>{{ getResourceQuantity(selectedListing, 'helmet6') }}</strong>
</div>
<div>
<span>6</span><strong>{{ getResourceQuantity(selectedListing, 'armor6') }}</strong>
</div>
</div>
<div v-if="selectedResources.length" class="resource-table">
<div v-for="resource in selectedResources" :key="resource.key">
@@ -693,7 +843,9 @@ function readError(error: unknown, fallback: string) {
</div>
</div>
<div class="skin-list">
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{ skin }}</el-tag>
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{
skin
}}</el-tag>
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div>
</section>
@@ -701,10 +853,18 @@ function readError(error: unknown, fallback: string) {
<section class="review-panel">
<div class="panel-title">
<h3>账号截图</h3>
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)">查看全部</el-button>
<el-button :icon="Picture" size="small" @click="openEvidence(selectedListing)"
>查看全部</el-button
>
</div>
<div v-if="selectedListing.screenshot_urls?.length" class="screenshot-grid">
<button v-for="url in selectedListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
<button
v-for="url in selectedListing.screenshot_urls"
:key="url"
type="button"
class="screenshot-tile"
@click="openScreenshot(url)"
>
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
@@ -721,15 +881,32 @@ function readError(error: unknown, fallback: string) {
</main>
</div>
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="620px" @update:model-value="activeListing = null">
<el-dialog
:model-value="!!activeListing"
title="拒绝发布"
width="620px"
@update:model-value="activeListing = null"
>
<div v-if="activeListing" class="dialog-body reject-dialog">
<p><strong>{{ activeListing.title }}</strong></p>
<p>
<strong>{{ activeListing.title }}</strong>
</p>
<div class="reject-reasons">
<el-button v-for="reason in rejectReasonOptions" :key="reason" size="small" @click="appendRejectReason(reason)">
<el-button
v-for="reason in rejectReasonOptions"
:key="reason"
size="small"
@click="appendRejectReason(reason)"
>
{{ reason }}
</el-button>
</div>
<el-input v-model="rejectReason" type="textarea" :rows="5" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
<el-input
v-model="rejectReason"
type="textarea"
:rows="5"
placeholder="填写拒绝原因,号主会在通知中看到审核结果"
/>
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
@@ -737,7 +914,12 @@ function readError(error: unknown, fallback: string) {
</template>
</el-dialog>
<el-dialog :model-value="!!priceAdjustListing" title="调整审核价格" width="560px" @update:model-value="priceAdjustListing = null">
<el-dialog
:model-value="!!priceAdjustListing"
title="调整审核价格"
width="560px"
@update:model-value="priceAdjustListing = null"
>
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
<div class="adjust-current">
<div>
@@ -746,11 +928,15 @@ function readError(error: unknown, fallback: string) {
</div>
<div>
<span>调整后买家价</span>
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong>
<strong class="preview-value">{{
previewMoney(priceAdjustPreview?.buyerTotalPrice || 0)
}}</strong>
</div>
<div>
<span>调整后买家比例</span>
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong>
<strong class="preview-value">{{
ratioText(priceAdjustPreview?.buyerRatio || 0)
}}</strong>
</div>
</div>
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
@@ -759,28 +945,61 @@ function readError(error: unknown, fallback: string) {
</el-radio-group>
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span>加价后比例</span>
<el-input-number v-model="priceAdjustForm.buyer_ratio" :min="0" :step="0.1" :controls="false" />
<el-input-number
v-model="priceAdjustForm.buyer_ratio"
:min="0"
:step="0.1"
:controls="false"
/>
</label>
<label v-else class="adjust-field">
<span>加价后价格</span>
<el-input-number v-model="priceAdjustForm.buyer_total_price" :min="0" :step="1" :controls="false" />
<el-input-number
v-model="priceAdjustForm.buyer_total_price"
:min="0"
:step="1"
:controls="false"
/>
</label>
<label class="adjust-field">
<span>调价原因</span>
<el-input v-model="priceAdjustForm.reason" type="textarea" :rows="3" placeholder="可填写调价原因,便于审计追踪" />
<el-input
v-model="priceAdjustForm.reason"
type="textarea"
:rows="3"
placeholder="可填写调价原因,便于审计追踪"
/>
</label>
</div>
<template #footer>
<el-button @click="priceAdjustListing = null">取消</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust">保存调整</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust"
>保存调整</el-button
>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
<el-dialog
:model-value="!!evidenceListing"
title="账号资产截图"
width="860px"
@update:model-value="evidenceListing = null"
>
<div v-if="evidenceListing" class="dialog-body">
<p><strong>{{ evidenceListing.title }}</strong></p>
<div v-if="evidenceListing.screenshot_urls?.length" class="screenshot-grid dialog-screenshots">
<button v-for="url in evidenceListing.screenshot_urls" :key="url" type="button" class="screenshot-tile" @click="openScreenshot(url)">
<p>
<strong>{{ evidenceListing.title }}</strong>
</p>
<div
v-if="evidenceListing.screenshot_urls?.length"
class="screenshot-grid dialog-screenshots"
>
<button
v-for="url in evidenceListing.screenshot_urls"
:key="url"
type="button"
class="screenshot-tile"
@click="openScreenshot(url)"
>
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
@@ -911,7 +1130,10 @@ function readError(error: unknown, fallback: string) {
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
.queue-item:hover,
@@ -3,7 +3,12 @@ import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import { fetchAdminListings, type AdminListingPage, type AdminListingQuery, type Listing } from '@/features/listings'
import {
fetchAdminListings,
type AdminListingPage,
type AdminListingQuery,
type Listing,
} from '@/features/listings'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { useMoney } from '@/shared/composables/useMoney'
import {
@@ -29,7 +34,11 @@ const filters = reactive<AdminListingQuery>({
const pageSize = ref(10)
const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
const {
loading,
data: listingPage,
load: loadListings,
} = useAdminTable<AdminListingPage>({
fetchFn: () =>
fetchAdminListings({
...filters,
@@ -46,9 +55,13 @@ const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminLi
const listings = computed(() => listingPage.value.items)
const totalListings = computed(() => listingPage.value.total)
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
const rentedCount = computed(() => listings.value.filter((item) => item.status === 'rented').length)
const pendingCount = computed(() => listings.value.filter((item) => item.review_status === 'pending').length)
const publishedCount = computed(
() => listings.value.filter(item => item.status === 'published').length
)
const rentedCount = computed(() => listings.value.filter(item => item.status === 'rented').length)
const pendingCount = computed(
() => listings.value.filter(item => item.review_status === 'pending').length
)
const totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const screenshotColumns = [
@@ -65,10 +78,18 @@ const screenshotColumns = [
{ label: '六头', width: 50, read: (row: Listing) => resourceText(row, 'helmet6') },
{ label: '六甲', width: 50, read: (row: Listing) => resourceText(row, 'armor6') },
{ label: '特殊刀皮', width: 110, read: (row: Listing) => skinGroupText(row, 'melee') },
{ label: '人物红皮/人物金皮/武器皮肤', width: 420, read: (row: Listing) => characterAndWeaponSkinText(row) },
{
label: '人物红皮/人物金皮/武器皮肤',
width: 420,
read: (row: Listing) => characterAndWeaponSkinText(row),
},
{ label: '租金/押金', width: 96, read: (row: Listing) => rentAndDepositText(row) },
{ label: '比例', width: 64, read: (row: Listing) => formatRatio(row) },
{ label: '租期', width: 92, read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}` },
{
label: '租期',
width: 92,
read: (row: Listing) => `${formatEstimatedRentalDuration(row)}\n日耗 ${getDailyLoss(row)}`,
},
] as const
async function queryListings() {
@@ -90,7 +111,9 @@ function setStatusFilter(status: string) {
}
function setReviewStatusFilter(reviewStatus: string) {
filters.review_status = (filters.review_status === reviewStatus ? '' : reviewStatus) as AdminListingQuery['review_status']
filters.review_status = (
filters.review_status === reviewStatus ? '' : reviewStatus
) as AdminListingQuery['review_status']
filters.status = ''
void queryListings()
}
@@ -143,7 +166,7 @@ async function downloadTableScreenshot() {
async function createTableScreenshotBlob() {
if (!listings.value.length) throw new Error('当前页暂无可截图数据')
const canvas = renderTableScreenshotCanvas(listings.value)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('截图生成失败')
return blob
}
@@ -165,14 +188,17 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
if (!measureCtx) throw new Error('截图画布初始化失败')
measureCtx.font = '13px Arial, "Microsoft YaHei", sans-serif'
const rowLines = rows.map((row) =>
screenshotColumns.map((column) => wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)),
const rowLines = rows.map(row =>
screenshotColumns.map(column =>
wrapCanvasText(measureCtx, column.read(row), column.width - cellPaddingX * 2)
)
)
const rowHeights = rowLines.map((lineGroups) => {
const maxLines = Math.max(...lineGroups.map((lines) => lines.length), 1)
const rowHeights = rowLines.map(lineGroups => {
const maxLines = Math.max(...lineGroups.map(lines => lines.length), 1)
return Math.max(44, maxLines * lineHeight + cellPaddingY * 2)
})
const canvasHeight = paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
const canvasHeight =
paddingY * 2 + titleHeight + headerHeight + rowHeights.reduce((sum, height) => sum + height, 0)
const canvas = document.createElement('canvas')
canvas.width = Math.round(canvasWidth * ratio)
canvas.height = Math.round(canvasHeight * ratio)
@@ -189,7 +215,11 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
ctx.fillText('商品管理', paddingX, paddingY + 20)
ctx.fillStyle = '#8f9bba'
ctx.font = '12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillText(`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value}`, paddingX, paddingY + 40)
ctx.fillText(
`当前页 ${rows.length} 条 / 查询结果 ${totalListings.value}`,
paddingX,
paddingY + 40
)
let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
@@ -211,7 +241,10 @@ function renderTableScreenshotCanvas(rows: Listing[]) {
for (const [columnIndex, column] of screenshotColumns.entries()) {
const lines = rowLines[rowIndex]?.[columnIndex] ?? ['-']
ctx.fillStyle = columnIndex === 0 ? '#4b5563' : '#3f4654'
ctx.font = columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif'
ctx.font =
columnIndex === 0
? '700 13px Arial, "Microsoft YaHei", sans-serif'
: '13px Arial, "Microsoft YaHei", sans-serif'
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
x += column.width
}
@@ -240,18 +273,37 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n
return lines
}
function drawCellText(ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number) {
function drawCellText(
ctx: CanvasRenderingContext2D,
lines: string[],
x: number,
y: number,
lineHeight: number
) {
lines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight)
})
}
function drawRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
function drawRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
color: string
) {
ctx.fillStyle = color
ctx.fillRect(x, y, width, height)
}
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
function drawLine(
ctx: CanvasRenderingContext2D,
startX: number,
startY: number,
endX: number,
endY: number
) {
ctx.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1
ctx.beginPath()
@@ -311,7 +363,7 @@ function characterAndWeaponSkinText(row: Listing) {
{ label: '武器', key: 'weapon' },
]
const parts = groups
.map((group) => {
.map(group => {
const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : ''
})
@@ -421,9 +473,13 @@ function formatQuantity(value: number) {
</el-form>
<div class="listing-action-strip">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="queryListings"
>查询</el-button
>
<el-button :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot"
>下载截图</el-button
>
</div>
</div>
</div>
@@ -504,7 +560,9 @@ function formatQuantity(value: number) {
</el-table>
<div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span>
<span class="pagination-summary"
>当前页 {{ listings.length }} {{ totalListings }} </span
>
<div class="pagination-controls">
<span class="pagination-size-label">每页</span>
<el-select
@@ -520,7 +578,9 @@ function formatQuantity(value: number) {
</el-select>
<span class="pagination-page">{{ currentPage }}/{{ totalPages }}</span>
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage"
>下页</el-button
>
</div>
</div>
</section>
@@ -1,63 +1,62 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { Lock, User } from "@element-plus/icons-vue";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from 'element-plus'
import { Lock, User } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchAdminCaptcha, type AdminCaptcha } from "@/features/admin";
import { useAdminSessionStore } from "@/stores/adminSession";
import { fetchAdminCaptcha, type AdminCaptcha } from '@/features/admin'
import { useAdminSessionStore } from '@/stores/adminSession'
const router = useRouter();
const adminSession = useAdminSessionStore();
const loading = ref(false);
const captchaLoading = ref(false);
const captcha = ref<AdminCaptcha | null>(null);
const router = useRouter()
const adminSession = useAdminSessionStore()
const loading = ref(false)
const captchaLoading = ref(false)
const captcha = ref<AdminCaptcha | null>(null)
const form = reactive({
username: "admin",
password: "admin123456",
captchaCode: "",
});
username: 'admin',
password: 'admin123456',
captchaCode: '',
})
onMounted(loadCaptcha);
onMounted(loadCaptcha)
async function loadCaptcha() {
captchaLoading.value = true;
captchaLoading.value = true
try {
captcha.value = await fetchAdminCaptcha();
form.captchaCode = "";
captcha.value = await fetchAdminCaptcha()
form.captchaCode = ''
} catch (error) {
ElMessage.error(readError(error, "验证码加载失败"));
ElMessage.error(readError(error, '验证码加载失败'))
} finally {
captchaLoading.value = false;
captchaLoading.value = false
}
}
async function handleLogin() {
loading.value = true;
loading.value = true
try {
await adminSession.login(
form.username,
form.password,
captcha.value?.captcha_id || "",
captcha.value?.captcha_id || '',
form.captchaCode
);
ElMessage.success("后台登录成功");
await router.push("/admin/dashboard");
)
ElMessage.success('后台登录成功')
await router.push('/admin/dashboard')
} catch (error) {
ElMessage.error(readError(error, "后台登录失败"));
await loadCaptcha();
ElMessage.error(readError(error, '后台登录失败'))
await loadCaptcha()
} finally {
loading.value = false;
loading.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback;
return fallback
}
</script>
@@ -151,16 +150,9 @@ function readError(error: unknown, fallback: string) {
min-height: 100vh;
place-items: center;
overflow: hidden;
background: radial-gradient(
circle at 18% 20%,
rgba(20, 119, 255, 0.14),
transparent 42%
),
radial-gradient(
circle at 82% 80%,
rgba(109, 40, 217, 0.12),
transparent 42%
),
background:
radial-gradient(circle at 18% 20%, rgba(20, 119, 255, 0.14), transparent 42%),
radial-gradient(circle at 82% 80%, rgba(109, 40, 217, 0.12), transparent 42%),
linear-gradient(180deg, #0b1120 0%, #0f172a 60%, #111827 100%);
padding: 24px;
}
@@ -172,11 +164,7 @@ function readError(error: unknown, fallback: string) {
width: 50vw;
height: 50vw;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(20, 119, 255, 0.22),
transparent 65%
);
background: radial-gradient(circle, rgba(20, 119, 255, 0.22), transparent 65%);
filter: blur(80px);
pointer-events: none;
}
@@ -185,11 +173,7 @@ function readError(error: unknown, fallback: string) {
left: auto;
right: -10%;
bottom: -10%;
background: radial-gradient(
circle,
rgba(109, 40, 217, 0.18),
transparent 65%
);
background: radial-gradient(circle, rgba(109, 40, 217, 0.18), transparent 65%);
}
.admin-login-card {
@@ -203,7 +187,8 @@ function readError(error: unknown, fallback: string) {
border-radius: 20px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(24px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35),
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
@@ -214,11 +199,8 @@ function readError(error: unknown, fallback: string) {
flex-direction: column;
justify-content: space-between;
padding: 40px 36px;
background: radial-gradient(
circle at 30% 20%,
rgba(20, 119, 255, 0.12),
transparent 50%
),
background:
radial-gradient(circle at 30% 20%, rgba(20, 119, 255, 0.12), transparent 50%),
radial-gradient(circle at 80% 90%, rgba(109, 40, 217, 0.1), transparent 50%),
linear-gradient(160deg, rgba(20, 119, 255, 0.1), rgba(109, 40, 217, 0.06));
}
@@ -312,14 +294,17 @@ function readError(error: unknown, fallback: string) {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
border-radius: 10px;
padding: 0 12px;
transition: box-shadow 0.2s, background 0.2s;
transition:
box-shadow 0.2s,
background 0.2s;
}
.admin-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.06);
}
.admin-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(20, 119, 255, 0.45) inset,
box-shadow:
0 0 0 1px rgba(20, 119, 255, 0.45) inset,
0 0 0 3px rgba(20, 119, 255, 0.08);
}
.admin-form :deep(.el-input__inner) {
@@ -352,7 +337,9 @@ function readError(error: unknown, fallback: string) {
padding: 0;
cursor: pointer;
overflow: hidden;
transition: background 0.2s, border-color 0.2s;
transition:
background 0.2s,
border-color 0.2s;
}
.captcha-image-button:hover {
background: rgba(255, 255, 255, 0.07);
@@ -383,7 +370,9 @@ function readError(error: unknown, fallback: string) {
background: linear-gradient(135deg, #1477ff, #2f8dff);
border: none;
box-shadow: 0 10px 28px rgba(20, 119, 255, 0.25);
transition: transform 0.15s, box-shadow 0.2s;
transition:
transform 0.15s,
box-shadow 0.2s;
}
.admin-login-btn:hover {
transform: translateY(-1px);
@@ -2,7 +2,12 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/features/admin/api/adminMgr'
import {
fetchAdminMgrUsers,
deleteAdminMgrUser,
changeAdminPassword,
type AdminMgrUser,
} from '@/features/admin/api/adminMgr'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time'
@@ -19,7 +24,15 @@ const passwordAdmin = ref<AdminMgrUser | null>(null)
const passwordForm = ref({ old_password: '', new_password: '' })
const passwordSubmitting = ref(false)
const { loading, data: admins, total, currentPage, currentPageSize, load: loadAdmins, handleSizeChange } = useAdminPaginatedTable<AdminMgrUser>({
const {
loading,
data: admins,
total,
currentPage,
currentPageSize,
load: loadAdmins,
handleSizeChange,
} = useAdminPaginatedTable<AdminMgrUser>({
fetchFn: fetchAdminMgrUsers,
})
@@ -46,11 +59,15 @@ function openPassword(row: AdminMgrUser) {
async function handleDelete(row: AdminMgrUser) {
try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await ElMessageBox.confirm(
`确定要删除管理员「${row.username}」吗?此操作不可撤销。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除')
await loadAdmins()
@@ -130,7 +147,9 @@ const statusLabel: Record<string, string> = {
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.last_login_at ? formatDateTime(row.last_login_at) : '-' }}</template>
<template #default="{ row }">{{
row.last_login_at ? formatDateTime(row.last_login_at) : '-'
}}</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
@@ -152,18 +171,10 @@ const statusLabel: Record<string, string> = {
/>
<!-- 新建/编辑对话框 -->
<AdminUserDialog
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<AdminUserDialog v-model="showDialog" :admin="editingAdmin" @saved="loadAdmins" />
<!-- 角色分配对话框 -->
<AssignRolesDialog
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<AssignRolesDialog v-model="showRolesDialog" :admin="rolesAdmin" @saved="loadAdmins" />
<!-- 修改密码对话框 -->
<el-dialog
@@ -174,15 +185,27 @@ const statusLabel: Record<string, string> = {
>
<div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入原密码" />
<el-input
v-model="passwordForm.old_password"
type="password"
show-password
placeholder="请输入原密码"
/>
</el-form-item>
<el-form-item label="新密码" class="full-control">
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="请输入新密码(至少6位)" />
<el-input
v-model="passwordForm.new_password"
type="password"
show-password
placeholder="请输入新密码(至少6位)"
/>
</el-form-item>
</div>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword">确认修改</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword"
>确认修改</el-button
>
</template>
</el-dialog>
</section>
@@ -3,7 +3,17 @@ import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { adminCloseOrder, adminMarkOrderAbnormal, adminRefundOrder, adminRefundStatus, fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order, type RefundStatus } from '@/features/orders'
import {
adminCloseOrder,
adminMarkOrderAbnormal,
adminRefundOrder,
adminRefundStatus,
fetchAdminHandoffRecords,
fetchAdminOrder,
type HandoffRecord,
type Order,
type RefundStatus,
} from '@/features/orders'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -19,7 +29,9 @@ const refunding = ref(false)
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
const actionTitle = computed(() => (actionType.value === 'close' ? '客服关闭订单' : '标记订单异常'))
const canOperate = computed(() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status))
const canOperate = computed(
() => !!order.value && !['completed', 'cancelled', 'closed'].includes(order.value.status)
)
onMounted(loadOrder)
@@ -138,9 +150,18 @@ function formatHandoffRecordType(type: string) {
<RouterLink to="/admin/orders">
<el-button>返回列表</el-button>
</RouterLink>
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')">标记异常</el-button>
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')">客服关闭</el-button>
<el-button type="primary" :loading="refunding" :disabled="refundStatus?.refund_status === 'refunded'" @click="handleRefund">
<el-button type="warning" :disabled="!canOperate" @click="openAction('abnormal')"
>标记异常</el-button
>
<el-button type="danger" :disabled="!canOperate" @click="openAction('close')"
>客服关闭</el-button
>
<el-button
type="primary"
:loading="refunding"
:disabled="refundStatus?.refund_status === 'refunded'"
@click="handleRefund"
>
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
</el-button>
</div>
@@ -170,7 +191,9 @@ function formatHandoffRecordType(type: string) {
<div v-if="refundStatus" class="metric-card">
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
<small v-if="refundStatus.refund_amount_cent > 0">¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small>
<small v-if="refundStatus.refund_amount_cent > 0"
>¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small
>
</div>
</div>
@@ -199,10 +222,22 @@ function formatHandoffRecordType(type: string) {
<pre>{{ snapshotText }}</pre>
</div>
<el-dialog :model-value="!!actionType" :title="actionTitle" width="560px" @update:model-value="actionType = ''">
<el-dialog
:model-value="!!actionType"
:title="actionTitle"
width="560px"
@update:model-value="actionType = ''"
>
<div v-if="order" class="dialog-body">
<p><strong>{{ order.order_no }}</strong> · {{ order.title }}</p>
<el-input v-model="reason" type="textarea" :rows="4" placeholder="填写客服操作原因,会写入审计日志并通知双方" />
<p>
<strong>{{ order.order_no }}</strong> · {{ order.title }}
</p>
<el-input
v-model="reason"
type="textarea"
:rows="4"
placeholder="填写客服操作原因,会写入审计日志并通知双方"
/>
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
@@ -32,7 +32,7 @@ async function handlePageChange() {
const filteredOrders = computed(() => {
if (!status.value) return orders.value
return orders.value.filter((item) => item.status === status.value)
return orders.value.filter(item => item.status === status.value)
})
</script>
@@ -117,11 +117,15 @@ async function handleDelete(row: PaymentConfig) {
async function handleExportBackup() {
try {
await ElMessageBox.confirm('备份文件会包含支付密钥明文,请妥善保管。确定导出吗?', '导出支付配置备份', {
type: 'warning',
confirmButtonText: '导出',
cancelButtonText: '取消',
})
await ElMessageBox.confirm(
'备份文件会包含支付密钥明文,请妥善保管。确定导出吗?',
'导出支付配置备份',
{
type: 'warning',
confirmButtonText: '导出',
cancelButtonText: '取消',
}
)
exporting.value = true
const { blob, filename } = await exportPaymentConfigBackup()
@@ -162,7 +166,7 @@ async function handleImportFile(event: Event) {
type: 'warning',
confirmButtonText: '导入',
cancelButtonText: '取消',
},
}
)
importing.value = true
@@ -256,18 +260,52 @@ function deleteDisabledReason(row: PaymentConfig) {
</div>
<div class="filter-bar">
<el-select v-model="filterProvider" placeholder="支付服务商" style="width: 150px" @change="loadConfigs">
<el-option v-for="opt in providerOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterProvider"
placeholder="支付服务商"
style="width: 150px"
@change="loadConfigs"
>
<el-option
v-for="opt in providerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterStatus" placeholder="状态" style="width: 120px" @change="loadConfigs">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterStatus"
placeholder="状态"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterEnvironment" placeholder="环境" style="width: 120px" @change="loadConfigs">
<el-option v-for="opt in environmentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-select
v-model="filterEnvironment"
placeholder="环境"
style="width: 120px"
@change="loadConfigs"
>
<el-option
v-for="opt in environmentOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button :icon="Refresh" @click="loadConfigs">刷新</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup">导入备份</el-button>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup">导出备份</el-button>
<el-button :icon="Upload" :loading="importing" @click="handleImportBackup"
>导入备份</el-button
>
<el-button :icon="Download" :loading="exporting" @click="handleExportBackup"
>导出备份</el-button
>
<el-button type="primary" :icon="Plus" @click="handleCreate">新增配置</el-button>
</div>
@@ -279,12 +317,7 @@ function deleteDisabledReason(row: PaymentConfig) {
@change="handleImportFile"
/>
<el-table
:data="filteredConfigs"
v-loading="loading"
class="payment-config-table"
stripe
>
<el-table :data="filteredConfigs" v-loading="loading" class="payment-config-table" stripe>
<el-table-column prop="name" label="配置名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="provider" label="服务商" width="120">
<template #default="{ row }">
@@ -327,10 +360,20 @@ function deleteDisabledReason(row: PaymentConfig) {
<el-table-column label="操作" width="226" class-name="operation-column">
<template #default="{ row }">
<div class="action-buttons">
<el-button class="action-button view" size="small" :icon="View" @click="handleView(row)">
<el-button
class="action-button view"
size="small"
:icon="View"
@click="handleView(row)"
>
查看
</el-button>
<el-button class="action-button edit" size="small" :icon="Edit" @click="handleEdit(row)">
<el-button
class="action-button edit"
size="small"
:icon="Edit"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-tooltip :content="deleteDisabledReason(row)" placement="top">
@@ -13,7 +13,11 @@ const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
const {
loading,
data: roles,
load: loadRoles,
} = useAdminTable<Role[]>({
fetchFn: fetchRoles,
})
@@ -34,11 +38,15 @@ function openPerms(row: Role) {
async function handleDelete(row: Role) {
try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await ElMessageBox.confirm(
`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteRole(row.id)
ElMessage.success('角色已删除')
await loadRoles()
@@ -85,18 +93,10 @@ async function handleDelete(row: Role) {
</el-table>
<!-- 新建/编辑对话框 -->
<RoleDialog
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<RoleDialog v-model="showDialog" :role="editingRole" @saved="loadRoles" />
<!-- 权限分配对话框 -->
<AssignPermissionsDialog
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
<AssignPermissionsDialog v-model="showPermsDialog" :role="permsRole" @saved="loadRoles" />
</section>
</template>
@@ -69,25 +69,39 @@ const agreementsVisible = ref(false)
const postRentalNoticeVisible = ref(false)
const generalVisible = ref(false)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null)
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
const listingPublishAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_agreements') || null)
const orderAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'order.agreements') || null)
const postRentalNoticeConfig = computed(() => configs.value.find((item) => item.key === 'profile.post_rental_notice') || null)
const publishConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_options') || null
)
const salePriceConfig = computed(
() => configs.value.find(item => item.key === 'listing.sale_price_config') || null
)
const homeAnnouncementsConfig = computed(
() => configs.value.find(item => item.key === 'mobile.home_announcements') || null
)
const homeBannersConfig = computed(
() => configs.value.find(item => item.key === 'mobile.home_banners') || null
)
const listingPublishAgreementsConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_agreements') || null
)
const orderAgreementsConfig = computed(
() => configs.value.find(item => item.key === 'order.agreements') || null
)
const postRentalNoticeConfig = computed(
() => configs.value.find(item => item.key === 'profile.post_rental_notice') || null
)
const regularConfigs = computed(() =>
configs.value.filter(
(item) =>
item =>
item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners' &&
item.key !== 'listing.publish_agreements' &&
item.key !== 'order.agreements' &&
item.key !== 'profile.post_rental_notice',
),
item.key !== 'profile.post_rental_notice'
)
)
// 只读计算属性,用于渲染卡片上的统计信息
@@ -134,7 +148,9 @@ const agreementStats = computed(() => {
})
const listingPublishAgreementStats = computed(() => {
const agreements = parseListingPublishAgreements(listingPublishAgreementsConfig.value?.value || '')
const agreements = parseListingPublishAgreements(
listingPublishAgreementsConfig.value?.value || ''
)
return {
saleTitle: agreements.virtual_asset_sale.title || '出售协议',
sellerTitle: agreements.seller_agreement.title || '号主协议',
@@ -199,12 +215,24 @@ function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements)
return {
virtual_asset_purchase: {
title: readText(parsed.virtual_asset_purchase?.title, defaultOrderAgreements.virtual_asset_purchase.title),
content: readText(parsed.virtual_asset_purchase?.content, defaultOrderAgreements.virtual_asset_purchase.content),
title: readText(
parsed.virtual_asset_purchase?.title,
defaultOrderAgreements.virtual_asset_purchase.title
),
content: readText(
parsed.virtual_asset_purchase?.content,
defaultOrderAgreements.virtual_asset_purchase.content
),
},
renter_agreement: {
title: readText(parsed.renter_agreement?.title, defaultOrderAgreements.renter_agreement.title),
content: readText(parsed.renter_agreement?.content, defaultOrderAgreements.renter_agreement.content),
title: readText(
parsed.renter_agreement?.title,
defaultOrderAgreements.renter_agreement.title
),
content: readText(
parsed.renter_agreement?.content,
defaultOrderAgreements.renter_agreement.content
),
},
}
}
@@ -213,12 +241,24 @@ function parseListingPublishAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultListingPublishAgreements)
return {
virtual_asset_sale: {
title: readText(parsed.virtual_asset_sale?.title, defaultListingPublishAgreements.virtual_asset_sale.title),
content: readText(parsed.virtual_asset_sale?.content, defaultListingPublishAgreements.virtual_asset_sale.content),
title: readText(
parsed.virtual_asset_sale?.title,
defaultListingPublishAgreements.virtual_asset_sale.title
),
content: readText(
parsed.virtual_asset_sale?.content,
defaultListingPublishAgreements.virtual_asset_sale.content
),
},
seller_agreement: {
title: readText(parsed.seller_agreement?.title, defaultListingPublishAgreements.seller_agreement.title),
content: readText(parsed.seller_agreement?.content, defaultListingPublishAgreements.seller_agreement.content),
title: readText(
parsed.seller_agreement?.title,
defaultListingPublishAgreements.seller_agreement.title
),
content: readText(
parsed.seller_agreement?.content,
defaultListingPublishAgreements.seller_agreement.content
),
},
}
}
@@ -343,8 +383,12 @@ function formatConfigValue(row: SystemConfig) {
<span>管理首页公告滚动内容和顶部轮播图保存后移动端会从接口读取最新配置</span>
</div>
<div class="panel-actions">
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)">编辑公告</el-button>
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)">编辑轮播图</el-button>
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)"
>编辑公告</el-button
>
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)"
>编辑轮播图</el-button
>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
@@ -378,7 +422,9 @@ function formatConfigValue(row: SystemConfig) {
<h2>发布协议配置</h2>
<span>管理发布账号前必须勾选确认的虚拟资产出售协议和号主协议</span>
</div>
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)">编辑发布协议</el-button>
<el-button type="primary" @click="openEdit(listingPublishAgreementsConfig)"
>编辑发布协议</el-button
>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
@@ -2,7 +2,12 @@
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/features/admin/api/adminUsers'
import {
fetchAdminUsers,
freezeAdminUser,
unfreezeAdminUser,
type AdminUserItem,
} from '@/features/admin/api/adminUsers'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -12,7 +17,15 @@ const submitting = ref(false)
const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const { loading, data: users, total, currentPage, currentPageSize, load: loadUsers, handleSizeChange } = useAdminPaginatedTable<AdminUserItem>({
const {
loading,
data: users,
total,
currentPage,
currentPageSize,
load: loadUsers,
handleSizeChange,
} = useAdminPaginatedTable<AdminUserItem>({
fetchFn: fetchAdminUsers,
})
@@ -81,10 +94,23 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" size="small" type="danger" :loading="submitting" @click="openFreeze(row)">
<el-button
v-if="row.status === 'active'"
size="small"
type="danger"
:loading="submitting"
@click="openFreeze(row)"
>
冻结
</el-button>
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
<el-button
v-else
size="small"
type="primary"
:loading="submitting"
@click="handleUnfreeze(row)"
>解冻</el-button
>
</template>
</el-table-column>
</el-table>
@@ -98,10 +124,22 @@ function readError(error: unknown, fallback: string) {
@page-change="loadUsers"
/>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<el-dialog
:model-value="!!activeUser"
title="冻结用户"
width="560px"
@update:model-value="activeUser = null"
>
<div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" />
<p>
<strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}
</p>
<el-input
v-model="freezeReason"
type="textarea"
:rows="4"
placeholder="填写冻结原因,便于审计追踪"
/>
</div>
<template #footer>
<el-button @click="activeUser = null">取消</el-button>
@@ -19,10 +19,14 @@ const filters = reactive({
})
const inAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
ledger.value
.filter(item => item.direction === 'in')
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
)
const outAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
ledger.value
.filter(item => item.direction === 'out')
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
)
onMounted(loadLedger)
@@ -89,7 +93,9 @@ function directionLabel(direction: string) {
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger"
>查询</el-button
>
</div>
</div>
@@ -134,7 +140,9 @@ function directionLabel(direction: string) {
</el-table-column>
<el-table-column label="订单" min-width="160">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{
row.order_no || row.order_id
}}</RouterLink>
<span v-else>-</span>
</template>
</el-table-column>
@@ -104,7 +104,7 @@ async function handleConfirmPayment(withdrawal: WithdrawalDetail) {
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '输入打款备注...',
inputValidator: (value) => {
inputValidator: value => {
return value && value.trim().length > 0
},
inputErrorMessage: '请输入打款备注',
@@ -202,19 +202,13 @@ function onDetailDialogSaved() {
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">
查询
</el-button>
<el-button type="primary" @click="handleFilter"> 查询 </el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 提现列表 -->
<el-table
v-loading="loading"
class="table-panel"
:data="withdrawals"
>
<el-table v-loading="loading" class="table-panel" :data="withdrawals">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
<el-table-column label="用户" min-width="140">
@@ -225,9 +219,7 @@ function onDetailDialogSaved() {
</el-table-column>
<el-table-column label="提现金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600">
¥{{ row.amount.toFixed(2) }}
</span>
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span>
</template>
</el-table-column>
<el-table-column label="收款方式" min-width="180">
@@ -257,9 +249,7 @@ function onDetailDialogSaved() {
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">
详情
</el-button>
<el-button size="small" @click="openDetail(row)"> 详情 </el-button>
<el-button
v-if="row.status === 'pending'"
size="small"