feat: P3阶段完成 - 全部模块迁移完成 🎉

## P3.1: 争议仲裁模块(disputes)
- API: disputes.ts
- 模块导出

## P3.2: 卖家中心模块(seller)
- Views: 4个页面
- Composables: usePublishForm, usePublishDraft
- 模块导出

## P3.3: 管理后台模块(admin)
- API: 8个文件(adminAuth, adminDashboard, adminUsers等)
- Views: 15个管理页面
- Composables: useAdminTable, useAdminPaginatedTable
- Components: 管理端组件
- 模块导出

---

## 🎉 Features 架构迁移全部完成!

### 最终统计
-  P0: shared(基础设施)- 22个文件
-  P1: wallet, chats, orders - 24个文件
-  P2: listings, auth - 35个文件
-  P3: seller, disputes, admin - 47个文件

**总计:** 9个模块,128个文件完成迁移

### 新架构
```
frontend/src/
├── features/          # 9个业务模块 
│   ├── wallet/       
│   ├── chats/        
│   ├── orders/        (已重构)
│   ├── listings/     
│   ├── auth/         
│   ├── seller/       
│   ├── disputes/     
│   └── admin/        
└── shared/           
```

下一步:清理旧文件、更新路由配置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-04 09:05:34 +08:00
co-authored by Claude Opus 4.7
parent 3534cffce1
commit c9397635e2
47 changed files with 9406 additions and 0 deletions
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminAuditLogs, type AdminAuditLog } from '@/api/adminAudit'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const logs = ref<AdminAuditLog[]>([])
const activeLog = ref<AdminAuditLog | null>(null)
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
actor_id: '',
action: '',
biz_type: '',
})
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
onMounted(loadLogs)
async function loadLogs() {
loading.value = true
try {
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminAuditLogs(query)
logs.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.actor_id = ''
filters.action = ''
filters.biz_type = ''
currentPage.value = 1
void loadLogs()
}
function handleSizeChange() {
currentPage.value = 1
loadLogs()
}
function actorName(row: AdminAuditLog) {
return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}`
}
function detailText(row: AdminAuditLog | null) {
if (!row?.detail) return '{}'
return JSON.stringify(row.detail, null, 2)
}
function actionType(action: string) {
if (action.includes('freeze')) return 'danger'
if (action.includes('update') || action.includes('create')) return 'warning'
return 'info'
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Audit Logs</p>
<h1>审计日志</h1>
<p>查看后台管理员操作业务对象和操作明细用于追踪冻结配置修改等高风险动作</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLogs">查询</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>高风险动作</span>
<strong>{{ highRiskCount }} </strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="管理员 ID">
<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-option label="冻结用户" value="admin_user.freeze" />
<el-option label="解冻用户" value="admin_user.unfreeze" />
<el-option label="后台下架商品" value="listing.admin_offline" />
<el-option label="商品标记异常" value="listing.mark_abnormal" />
<el-option label="客服关闭订单" value="order.admin_close" />
<el-option label="订单标记异常" value="order.mark_abnormal" />
<el-option label="申诉仲裁" value="dispute.arbitrate" />
<el-option label="更新系统配置" value="system_config.update" />
</el-select>
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="用户" value="user" />
<el-option label="商品" value="listing" />
<el-option label="订单" value="order" />
<el-option label="申诉" value="dispute" />
<el-option label="系统配置" value="system_config" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="logs">
<el-table-column label="操作人" min-width="160">
<template #default="{ row }">
<strong>{{ actorName(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="动作" min-width="180">
<template #default="{ row }">
<el-tag :type="actionType(row.action)">{{ row.action }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="130" />
<el-table-column prop="biz_id" label="业务 ID" width="100" />
<el-table-column label="时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="activeLog = row">明细</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLogs"
@size-change="handleSizeChange"
/>
</div>
<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>操作人{{ actorName(activeLog) }} · IP{{ activeLog.ip }}</p>
<p>User-Agent{{ activeLog.user_agent }}</p>
<div class="code-panel">
<pre>{{ detailText(activeLog) }}</pre>
</div>
</div>
<template #footer>
<el-button type="primary" @click="activeLog = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,751 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Picture } from '@element-plus/icons-vue'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
fetchQuickReplies,
markAdminChatRead,
sendAdminChatMessage,
updateChatRemark,
type ChatConversation,
type ChatMessage,
type QuickReply,
} from '@/api/chats'
import { uploadAdminFile } from '@/api/files'
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
import TransferDialog from './components/TransferDialog.vue'
import QuickReplyDialog from './components/QuickReplyDialog.vue'
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
const conversations = ref<ChatConversation[]>([])
const active = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const uploading = ref(false)
const content = ref('')
const attachments = ref<string[]>([])
const listRef = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
const transferVisible = ref(false)
const quickReplyVisible = ref(false)
const quickReplies = ref<QuickReply[]>([])
const remarkEditing = ref(false)
const remarkValue = ref('')
const activeMembers = computed(() => {
const participants = active.value?.participants || []
return participants.map(item => {
const remark = getParticipantRemark(item)
const name = remark ? `${remark}(${item.display_name})` : item.display_name
return `${roleLabel(item.role)}${name}`
}).join(' / ')
})
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
function getParticipantRemark(participant: any) {
if (!active.value) return ''
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || ''
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'conversation_updated') {
loadConversations(false)
}
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
const msg = event.message
if (msg && !messages.value.some(m => m.id === msg.id)) {
messages.value = [...messages.value, {
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
}]
nextTick(() => scrollBottom())
}
}
}
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await Promise.all([loadConversations(), loadQuickReplies()])
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100, filter.value)
conversations.value = res.items
const first = conversations.value[0]
if (!active.value && first) {
await openConversation(first)
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.value = false
}
}
async function loadQuickReplies() {
try {
quickReplies.value = await fetchQuickReplies()
} catch { /* ignore */ }
}
async function openConversation(item: ChatConversation) {
messageLoading.value = true
try {
active.value = await fetchAdminChat(item.id)
await loadMessages(item.id)
await markAdminChatRead(item.id)
await loadConversations(false)
remarkEditing.value = false
remarkValue.value = ''
attachments.value = []
} catch {
ElMessage.error('会话详情加载失败')
} finally {
messageLoading.value = false
}
}
async function loadMessages(id: number, scroll = true) {
const res = await fetchAdminChatMessages(id, 1, 100)
messages.value = res.items
if (scroll) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
const imageUrls = [...attachments.value]
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
sending.value = true
try {
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
appendMessage(sent)
content.value = ''
attachments.value = []
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function pickImages() {
if (uploading.value || attachments.value.length >= 9) return
fileInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const slots = 9 - attachments.value.length
if (slots <= 0) {
ElMessage.warning('每条消息最多发送 9 张图片')
return
}
uploading.value = true
try {
for (const file of files.slice(0, slots)) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
ElMessage.warning(`${file.name} 不符合图片规则`)
continue
}
const uploaded = await uploadAdminFile(file, 'chat')
attachments.value.push(uploaded.url)
}
if (files.length > slots) {
ElMessage.warning('每条消息最多发送 9 张图片')
}
} catch {
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1)
}
function handleQuickReplySelect(reply: QuickReply) {
content.value = reply.content
quickReplyVisible.value = false
}
function handleFilterChange(val: string) {
filter.value = val as typeof filter.value
active.value = null
messages.value = []
loadConversations()
}
function handleTransferSuccess() {
loadConversations(false)
if (active.value) {
loadMessages(active.value.id, false)
}
}
async function startEditRemark() {
if (!active.value) return
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
remarkValue.value = myParticipant?.remark || active.value.title
remarkEditing.value = true
}
async function saveRemark() {
if (!active.value) return
try {
await updateChatRemark(active.value.id, remarkValue.value)
ElMessage.success('备注已更新')
remarkEditing.value = false
await fetchAdminChat(active.value.id).then(chat => {
active.value = chat
})
await loadConversations(false)
} catch {
ElMessage.error('更新备注失败')
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统'
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
}
function getConversationTitle(item: ChatConversation) {
const myParticipant = item.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || item.title
}
function getSupportName(item: ChatConversation) {
const support = item.participants?.find(p => p.role === 'support')
return support?.display_name || '未分配'
}
</script>
<template>
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服会话</h1>
<p>处理订单三方沟通和平台咨询</p>
</div>
<div class="head-right">
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
</div>
</div>
<div class="chat-workbench">
<aside class="conversation-pane" v-loading="loading">
<div class="filter-tabs">
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
<el-radio-button value="mine">我的会话</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="unassigned">未分配</el-radio-button>
</el-radio-group>
</div>
<button
v-for="item in conversations"
:key="item.id"
type="button"
class="conversation-row"
:class="{ active: active?.id === item.id }"
@click="openConversation(item)"
>
<div class="row-title">
<strong>{{ getConversationTitle(item) }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
<div class="row-meta">
<span class="support-name">{{ getSupportName(item) }}</span>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
</div>
</button>
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
</aside>
<main class="message-pane">
<template v-if="active">
<header class="message-head">
<div class="head-title">
<template v-if="remarkEditing">
<el-input
v-model="remarkValue"
size="small"
style="width: 200px"
placeholder="输入备注"
@keyup.enter="saveRemark"
/>
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
<el-button size="small" @click="remarkEditing = false">取消</el-button>
</template>
<template v-else>
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
<p>{{ activeMembers }}</p>
</template>
</div>
<div class="head-actions">
<el-button size="small" @click="transferVisible = true">转接</el-button>
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
<el-button size="small">查看订单</el-button>
</RouterLink>
</div>
</header>
<div ref="listRef" class="message-list" v-loading="messageLoading">
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span>
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p v-if="item.content">{{ item.content }}</p>
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
<ChatAttachmentImage
v-for="url in item.attachment_urls"
:key="url"
:source="url"
admin
/>
</div>
</template>
</div>
</div>
<footer class="composer">
<div class="composer-tools">
<input
ref="fileInputRef"
class="hidden-file"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
@change="handleImageChange"
>
<el-dropdown trigger="click" @command="handleQuickReplySelect">
<el-button size="small" text>快捷回复</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="reply in quickReplies"
:key="reply.id"
:command="reply"
>
<span class="reply-title">{{ reply.title }}</span>
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
</el-dropdown-item>
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
暂无快捷回复
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
图片
</el-button>
</div>
<div v-if="attachments.length > 0" class="pending-attachments">
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
<ChatAttachmentImage :source="url" admin />
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
<el-icon :size="14"><Close /></el-icon>
</button>
</div>
</div>
<div class="composer-input">
<el-input
v-model="content"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
</div>
</footer>
</template>
<el-empty v-else description="请选择会话" />
</main>
</div>
<TransferDialog
v-if="active"
v-model="transferVisible"
:conversation-id="active.id"
@success="handleTransferSuccess"
/>
<QuickReplyDialog
v-model="quickReplyVisible"
@success="loadQuickReplies"
/>
</section>
</template>
<style scoped>
.admin-page {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.page-head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.head-right {
display: flex;
gap: 8px;
}
.chat-workbench {
display: grid;
min-height: 0;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
min-height: 0;
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.filter-tabs {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.support-name {
color: #8a94a6;
font-size: 12px;
}
.row-meta em {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.head-title {
flex: 1;
min-width: 0;
}
.head-title h2 {
margin: 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.head-title p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.head-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.head-actions a {
text-decoration: none;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 18px;
background: #f3f6fa;
}
.message-row {
max-width: 70%;
margin-bottom: 14px;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-attachments {
display: grid;
justify-items: start;
gap: 6px;
}
.message-row.self .message-attachments {
justify-items: end;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
border-top: 1px solid #e5e7eb;
}
.composer-tools {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
gap: 8px;
padding: 10px 14px 0;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 84px;
height: 84px;
}
.pending-item :deep(.chat-image-button img) {
height: 84px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.composer-input {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
}
.reply-title {
font-weight: 500;
margin-right: 8px;
}
.reply-preview {
color: #9ca3af;
font-size: 12px;
}
</style>
@@ -0,0 +1,494 @@
<script setup lang="ts">
import {
Bell,
ChatDotRound,
Document,
Grid,
Loading,
Operation,
Refresh,
ScaleToOriginal,
ShoppingBag,
Shop,
Tickets,
User,
Wallet,
Warning
} from '@element-plus/icons-vue'
import { fetchAdminDashboard, type AdminDashboard } from '@/api/adminDashboard'
import { useAdminTable } from '@/composables/useAdminTable'
import { useMoney } from '@/composables/useMoney'
import { disputeStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const money = useMoney()
const { loading, error, data: dashboard, load: loadDashboard } = useAdminTable<AdminDashboard>({
fetchFn: fetchAdminDashboard,
})
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Dashboard</p>
<h1>后台仪表盘</h1>
<p>实时监控平台运营数据快速处理待办事项</p>
</div>
<el-button @click="loadDashboard" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新数据
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="loading && !dashboard" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error && !dashboard" class="error-state">
<el-icon><Warning /></el-icon>
<span>{{ error }}</span>
<el-button @click="loadDashboard" type="primary">重试</el-button>
</div>
<!-- 核心指标卡片 -->
<div v-if="dashboard" class="metric-grid dashboard-metrics">
<div class="metric-card metric-card--primary">
<div class="metric-icon">
<el-icon><User /></el-icon>
</div>
<div class="metric-content">
<span>用户总数</span>
<strong>{{ dashboard.metrics.total_users }}</strong>
<small>实名 {{ dashboard.metrics.verified_users }} </small>
</div>
</div>
<div class="metric-card metric-card--success">
<div class="metric-icon">
<el-icon><ShoppingBag /></el-icon>
</div>
<div class="metric-content">
<span>发布商品</span>
<strong>{{ dashboard.metrics.total_listings }}</strong>
<small>已上架 {{ dashboard.metrics.published_listings }} </small>
</div>
</div>
<div class="metric-card metric-card--warning">
<div class="metric-icon">
<el-icon><Document /></el-icon>
</div>
<div class="metric-content">
<span>订单总数</span>
<strong>{{ dashboard.metrics.total_orders }}</strong>
<small>使用中 {{ dashboard.metrics.renting_orders }} </small>
</div>
</div>
<div class="metric-card metric-card--danger">
<div class="metric-icon">
<el-icon><Wallet /></el-icon>
</div>
<div class="metric-content">
<span>今日流水</span>
<strong>{{ money(dashboard.metrics.today_ledger_amount) }}</strong>
<small>今日订单 {{ dashboard.metrics.today_orders }} </small>
</div>
</div>
</div>
<!-- 待处理事项 & 快捷入口 -->
<div v-if="dashboard" class="dashboard-panels">
<div class="dashboard-panel">
<div class="panel-header">
<h2>
<el-icon><Bell /></el-icon>
待处理事项
</h2>
<el-tag type="warning" effect="dark" round>
{{ dashboard.pending.disputes + dashboard.pending.listing_reviews + dashboard.pending.pending_handoffs + dashboard.pending.pending_return_confirms }}
</el-tag>
</div>
<div class="pending-list">
<RouterLink class="pending-row" to="/admin/disputes">
<div class="pending-info">
<span class="pending-label">待仲裁申诉</span>
<span class="pending-desc">需要处理的用户争议</span>
</div>
<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>
</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>
</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>
</div>
</div>
</div>
<div class="dashboard-panel">
<div class="panel-header">
<h2>
<el-icon><Grid /></el-icon>
快捷入口
</h2>
</div>
<div class="quick-links">
<RouterLink class="quick-link" to="/admin/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/orders">
<el-icon><Tickets /></el-icon>
<span>订单管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/disputes">
<el-icon><ScaleToOriginal /></el-icon>
<span>仲裁中心</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/listings">
<el-icon><Shop /></el-icon>
<span>商品管理</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/chats">
<el-icon><ChatDotRound /></el-icon>
<span>客服群聊</span>
</RouterLink>
<RouterLink class="quick-link" to="/admin/system-configs">
<el-icon><Operation /></el-icon>
<span>系统配置</span>
</RouterLink>
</div>
</div>
</div>
<!-- 最近订单 -->
<div v-if="dashboard" class="dashboard-section">
<h2 class="section-title">
<el-icon><Tickets /></el-icon>
最近订单
</h2>
<el-table class="table-panel" :data="dashboard.recent_orders">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<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">
{{ orderStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rent_amount" label="金额" width="100">
<template #default="{ row }">
<span class="amount">{{ money(row.rent_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
</el-table>
</div>
<!-- 最近申诉 -->
<div v-if="dashboard && dashboard.recent_disputes.length > 0" class="dashboard-section">
<h2 class="section-title">
<el-icon><Warning /></el-icon>
最近申诉
</h2>
<el-table class="table-panel" :data="dashboard.recent_disputes">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<el-table-column prop="title" label="商品名称" min-width="170" />
<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">
{{ disputeStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default>
<RouterLink to="/admin/disputes">
<el-button size="small" type="primary" link>处理</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</div>
</section>
</template>
<style scoped>
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 300px;
color: #8f9bba;
}
.loading-state .el-icon {
font-size: 40px;
color: #4f7cff;
}
.loading-state span {
font-size: 14px;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 300px;
color: #ef4444;
}
.error-state .el-icon {
font-size: 40px;
}
.error-state span {
font-size: 14px;
color: #8f9bba;
}
.metric-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.metric-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.metric-card--primary .metric-icon {
background: rgba(79, 124, 255, 0.1);
color: #4f7cff;
}
.metric-card--success .metric-icon {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.metric-card--warning .metric-icon {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.metric-card--danger .metric-icon {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.metric-content {
flex: 1;
min-width: 0;
}
.metric-content span {
font-size: 13px;
color: #8f9bba;
}
.metric-content strong {
display: block;
margin-top: 4px;
font-size: 28px;
font-weight: 800;
color: #1b2559;
line-height: 1;
}
.metric-content small {
display: block;
margin-top: 8px;
font-size: 12px;
color: #a3aed0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.panel-header h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1b2559;
}
.panel-header h2 .el-icon {
color: #4f7cff;
}
.pending-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.pending-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 10px;
text-decoration: none;
transition: background 0.2s;
}
.pending-row:hover {
background: #f8f9fe;
}
.pending-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.pending-label {
font-size: 14px;
font-weight: 600;
color: #1b2559;
}
.pending-desc {
font-size: 12px;
color: #a3aed0;
}
.pending-row strong {
font-size: 20px;
font-weight: 800;
color: #10b981;
min-width: 32px;
text-align: center;
}
.pending-row strong.has-pending {
color: #ef4444;
}
.quick-links {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.quick-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 16px;
border-radius: 12px;
background: #f8f9fe;
text-decoration: none;
transition: all 0.2s;
}
.quick-link:hover {
background: #eef2ff;
transform: translateY(-2px);
}
.quick-link .el-icon {
font-size: 24px;
color: #4f7cff;
}
.quick-link span {
font-size: 13px;
font-weight: 600;
color: #1b2559;
}
.dashboard-section {
margin-top: 28px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 16px;
font-weight: 700;
color: #1b2559;
}
.section-title .el-icon {
color: #4f7cff;
}
.amount {
font-weight: 700;
color: #1b2559;
}
@media (max-width: 768px) {
.quick-links {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.metric-card {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.metric-icon {
width: 40px;
height: 40px;
font-size: 18px;
}
.metric-content strong {
font-size: 24px;
}
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
import { fetchAdminFileBlob } from '@/api/files'
import { disputeStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const submitting = ref(false)
const disputes = ref<Dispute[]>([])
const activeDispute = ref<Dispute | null>(null)
const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadDisputes)
async function loadDisputes() {
loading.value = true
try {
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
disputes.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadDisputes()
}
function openArbitration(row: Dispute) {
activeDispute.value = row
result.value = row.arbitration_result || 'release_deposit'
remark.value = row.arbitration_remark || ''
amount.value = undefined
}
function evidenceItems(row: Dispute | null) {
const raw = row?.evidence_urls
if (!raw) return []
if (Array.isArray(raw)) return raw
return []
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function openEvidence(url: string) {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
try {
const blob = await fetchAdminFileBlob(key)
const objectURL = URL.createObjectURL(blob)
window.open(objectURL, '_blank')
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000)
} catch (error) {
ElMessage.error(readError(error, '证据文件打开失败'))
}
}
async function handleArbitrate() {
if (!activeDispute.value) return
submitting.value = true
try {
await arbitrateDispute(activeDispute.value.id, {
result: result.value,
remark: remark.value,
amount: amount.value,
})
ElMessage.success('仲裁结果已保存,双方已收到通知')
activeDispute.value = null
remark.value = ''
await loadDisputes()
} catch (error) {
ElMessage.error(readError(error, '仲裁失败'))
} finally {
submitting.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
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Arbitration</p>
<h1>仲裁中心</h1>
<p>处理无法登录资产损失哈夫币争议和结账争议</p>
</div>
<el-table v-loading="loading" class="table-panel" :data="disputes">
<el-table-column prop="order_no" label="订单号" min-width="210" />
<el-table-column prop="title" label="账号" min-width="160" />
<el-table-column prop="type" label="类型" width="150" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
</el-table-column>
<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 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>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDisputes"
@size-change="handleSizeChange"
/>
</div>
<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>{{ activeDispute.description }}</p>
<el-select v-model="result" class="full-control" placeholder="选择裁决结果">
<el-option label="全额退款" value="full_refund" />
<el-option label="部分退款" value="partial_refund" />
<el-option label="扣押金" value="deduct_deposit" />
<el-option label="释放押金" value="release_deposit" />
<el-option label="赔付号主" value="compensate_owner" />
<el-option label="关闭订单" value="order_close" />
<el-option label="标记异常" value="mark_abnormal" />
</el-select>
<el-input-number
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
v-model="amount"
class="full-control panel-action"
:min="0"
:precision="0"
: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="填写客服裁决说明" />
</div>
<template #footer>
<el-button @click="activeDispute = null">取消</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">
<div v-if="evidenceDispute" class="dialog-body">
<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>
</div>
</div>
<template #footer>
<el-button type="primary" @click="evidenceDispute = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/api/files'
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
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))
onMounted(loadListing)
async function loadListing() {
loading.value = true
try {
listing.value = await fetchAdminListing(String(route.params.id))
} finally {
loading.value = false
}
}
function openAction(type: 'offline' | 'abnormal') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!listing.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'offline') {
listing.value = await adminOfflineListing(listing.value.id, reason.value)
ElMessage.success('商品已强制下架')
} else {
listing.value = await adminMarkListingAbnormal(listing.value.id, reason.value)
ElMessage.success('商品已标记异常')
}
actionType.value = ''
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function listingPrice(row: Listing) {
return money(row.price)
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || url
} catch {
return url
}
}
async function openScreenshot(url: string) {
try {
const blob = await fetchAdminFileBlob(extractObjectKey(url))
window.open(URL.createObjectURL(blob), '_blank')
} catch {
window.open(url, '_blank')
}
}
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
}
return fallback
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="listing" class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listing #{{ listing.id }}</p>
<h1>商品详情</h1>
<p>{{ listing.title }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
</div>
<div class="toolbar-actions">
<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>
</div>
</div>
<div v-if="listing" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>商品状态</span>
<strong>{{ listingStatusLabel(listing.status) }}</strong>
</div>
<div class="metric-card">
<span>审核状态</span>
<strong>{{ listingReviewStatusLabel(listing.review_status) }}</strong>
</div>
<div class="metric-card">
<span>价格</span>
<strong>{{ listingPrice(listing) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(listing.deposit_amount) }}</strong>
</div>
</div>
<div v-if="listing" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>账号信息</h2>
<p>账号 ID{{ listing.account_id }}</p>
<p>游戏{{ listing.game_name }}</p>
<p>区服{{ listing.server_region }}</p>
<p>平台{{ listing.login_platform }}</p>
<p>段位{{ listing.rank_level || '-' }}</p>
<p>哈夫币{{ listing.haf_coin_amount }}</p>
<p>资产截图{{ listing.screenshot_urls?.length || 0 }} </p>
</div>
<div class="order-panel dashboard-panel">
<h2>号主与价格</h2>
<p>号主{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</p>
<p>号主 ID{{ listing.owner_id }}</p>
<p>价格{{ listingPrice(listing) }}</p>
<p>押金{{ money(listing.deposit_amount) }}</p>
</div>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>说明与审核原因</h2>
<p>{{ listing.description || '暂无商品说明' }}</p>
<p>审核/后台原因{{ listing.review_reason || '-' }}</p>
<p>上架时间{{ formatDateTime(listing.published_at) }}</p>
<p>更新时间{{ formatDateTime(listing.updated_at) }}</p>
</div>
<div v-if="listing" class="order-panel dashboard-panel">
<h2>资产截图</h2>
<div v-if="listing.screenshot_urls?.length" class="evidence-list">
<div v-for="url in listing.screenshot_urls" :key="url" class="evidence-row">
<span>{{ url }}</span>
<el-button size="small" @click="openScreenshot(url)">打开</el-button>
</div>
</div>
<p v-else>暂无截图</p>
</div>
<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="填写后台操作原因,会写入审计日志并通知号主" />
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,963 @@
<script setup lang="ts">
import { Check, Close, Picture, Refresh, Search, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/api/files'
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getListingConsumablePrice,
getListingResources,
getResourceQuantity,
getSkinNames,
readAssetNumber,
readAssetString,
} from '@/utils/listingDisplay'
import { formatDateTime } from '@/utils/time'
type RiskLevel = 'danger' | 'warning' | 'info'
interface RiskItem {
label: string
level: RiskLevel
}
const defaultScreenshotURL = '/api/listings/default-upload-screenshot'
const rejectReasonOptions = ['默认截图,需补充真实截图', '账号资产信息不完整', '价格或押金异常', '封禁记录需补充说明', '联系方式异常']
const loading = ref(false)
const submitting = ref(false)
const listings = ref<Listing[]>([])
const selectedID = ref<number | null>(null)
const activeListing = ref<Listing | null>(null)
const evidenceListing = ref<Listing | null>(null)
const rejectReason = ref('')
const filters = reactive({
keyword: '',
risk: 'all',
})
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 filteredListings = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
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
if (!keyword) return true
return reviewSearchText(item).includes(keyword)
})
})
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
watch(
() => selectedListing.value,
async (listing) => {
if (!listing) return
selectedID.value = listing.id
await nextTick()
loadScreenshotPreviews(listing)
},
{ immediate: true },
)
onMounted(loadListings)
onBeforeUnmount(() => {
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)) {
selectedID.value = listings.value[0]?.id || null
}
} finally {
loading.value = false
}
}
function selectListing(row: Listing) {
selectedID.value = row.id
}
async function handleApprove(row: Listing) {
try {
await ElMessageBox.confirm(`确认通过「${row.title}」并上架?`, '审核通过确认', {
confirmButtonText: '通过并上架',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
try {
await approveListing(row.id)
ElMessage.success('审核通过,商品已上架')
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '审核失败'))
} finally {
submitting.value = false
}
}
function openReject(row: Listing) {
activeListing.value = row
rejectReason.value = row.review_reason || ''
}
function appendRejectReason(reason: string) {
const current = rejectReason.value.trim()
if (!current) {
rejectReason.value = reason
return
}
if (!current.includes(reason)) {
rejectReason.value = `${current}${reason}`
}
}
async function handleReject() {
if (!activeListing.value) return
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写拒绝原因')
return
}
submitting.value = true
try {
await rejectListing(activeListing.value.id, reason)
ElMessage.success('已拒绝发布并通知号主')
activeListing.value = null
rejectReason.value = ''
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '拒绝失败'))
} finally {
submitting.value = false
}
}
function openEvidence(row: Listing) {
evidenceListing.value = row
loadScreenshotPreviews(row)
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function quantity(value: number) {
const rounded = Math.round(Number(value || 0) * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
function assetText(row: Listing, key: string) {
return readAssetString(row, key) || '-'
}
function assetNumberText(row: Listing, key: string) {
const value = readAssetNumber(row, key)
return value > 0 ? quantity(value) : '-'
}
function dailyLossText(row: Listing) {
const value = readAssetNumber(row, 'daily_loss_m')
return value > 0 ? `${quantity(value)}M` : '未上传'
}
function uploadMeta(row: Listing) {
const meta = row.asset_summary?.import_meta
return typeof meta === 'object' && meta !== null ? (meta as Record<string, unknown>) : {}
}
function uploaderName(row: Listing) {
const value = uploadMeta(row).uploader_name
return typeof value === 'string' && value.trim() ? value : '-'
}
function contactPhone(row: Listing) {
const value = uploadMeta(row).contact_phone
return typeof value === 'string' && value.trim() ? value : '-'
}
function ownerOnlineText(row: Listing) {
const text = row.asset_summary?.online_time_text
if (typeof text === 'string' && text.trim()) return text
const onlineTime = row.asset_summary?.online_time
if (typeof onlineTime !== 'object' || onlineTime === null) return '-'
const start = (onlineTime as Record<string, unknown>).start
const end = (onlineTime as Record<string, unknown>).end
if (typeof start === 'string' && typeof end === 'string' && start && end) return `${start}-${end}`
return '-'
}
function commonRegionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function banRecordText(row: Listing) {
return assetText(row, 'ban_record')
}
function hasBanRecord(row: Listing) {
const value = banRecordText(row)
return value !== '-' && !value.includes('无')
}
function isExternalUpload(row: Listing) {
return uploaderName(row) !== '-'
}
function hasDefaultScreenshot(row: Listing) {
return row.screenshot_urls?.some((url) => isDefaultScreenshot(url)) || false
}
function isDefaultScreenshot(url: string) {
return url.includes(defaultScreenshotURL)
}
function riskItems(row: Listing): RiskItem[] {
const items: RiskItem[] = []
if (isExternalUpload(row)) items.push({ label: `外部上传:${uploaderName(row)}`, level: 'info' })
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)) {
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 (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
return items
}
function riskTagType(level: RiskLevel) {
if (level === 'danger') return 'danger'
if (level === 'warning') return 'warning'
return 'info'
}
function reviewSearchText(row: Listing) {
return [
row.title,
row.owner_phone,
row.owner_nickname,
row.owner_id,
row.server_region,
row.login_platform,
row.rank_level,
uploaderName(row),
getSkinNames(row).join(' '),
]
.join(' ')
.toLowerCase()
}
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function loadScreenshotPreviews(row: Listing) {
for (const url of row.screenshot_urls || []) {
if (previewURLs[url]) continue
if (isDefaultScreenshot(url) || !extractObjectKey(url)) {
previewURLs[url] = url
continue
}
try {
const blob = await fetchAdminFileBlob(extractObjectKey(url))
const objectURL = URL.createObjectURL(blob)
createdObjectURLs.add(objectURL)
previewURLs[url] = objectURL
} catch {
previewURLs[url] = url
}
}
}
async function openScreenshot(url: string) {
if (isDefaultScreenshot(url)) {
window.open(url, '_blank')
return
}
try {
const key = extractObjectKey(url)
if (!key) {
window.open(url, '_blank')
return
}
const blob = await fetchAdminFileBlob(key)
window.open(URL.createObjectURL(blob), '_blank')
} catch {
window.open(url, '_blank')
}
}
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
}
return fallback
}
</script>
<template>
<section class="page review-page">
<div class="page-header-row review-header">
<div class="page-header">
<p class="eyebrow">Review</p>
<h1>商品审核</h1>
<p>集中核对账号资产上传来源价格押金和截图风险</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadListings">刷新</el-button>
</div>
</div>
<div class="review-summary">
<div>
<span>待审核</span>
<strong>{{ listings.length }}</strong>
</div>
<div>
<span>外部上传</span>
<strong>{{ listings.filter(isExternalUpload).length }}</strong>
</div>
<div>
<span>默认截图</span>
<strong>{{ listings.filter(hasDefaultScreenshot).length }}</strong>
</div>
<div>
<span>封禁风险</span>
<strong>{{ listings.filter(hasBanRecord).length }}</strong>
</div>
</div>
<div class="review-workbench">
<aside class="review-queue">
<div class="queue-toolbar">
<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" />
<el-option label="默认截图" value="defaultImage" />
<el-option label="有封禁记录" value="ban" />
</el-select>
</div>
<div v-loading="loading" class="queue-list">
<button
v-for="item in filteredListings"
:key="item.id"
class="queue-item"
:class="{ active: selectedListing?.id === item.id }"
type="button"
@click="selectListing(item)"
>
<div class="queue-title-row">
<strong>{{ item.title }}</strong>
<span>{{ formatHafCoinM(getCoinWan(item)) }}</span>
</div>
<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>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="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
</div>
</button>
<el-empty v-if="!loading && !filteredListings.length" description="暂无符合条件的待审核商品" />
</div>
</aside>
<main v-if="selectedListing" class="review-detail">
<section class="review-hero-panel">
<div class="hero-title">
<div>
<p>商品 #{{ selectedListing.id }}</p>
<h2>{{ selectedListing.title }}</h2>
</div>
<div class="hero-actions">
<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>
</div>
</div>
<div class="risk-strip">
<el-tag v-for="risk in activeRisks" :key="risk.label" :type="riskTagType(risk.level)" effect="light">
{{ risk.label }}
</el-tag>
</div>
</section>
<section class="metric-grid review-metrics">
<div class="metric-card">
<span>哈夫币</span>
<strong>{{ formatHafCoinM(getCoinWan(selectedListing)) }}</strong>
</div>
<div class="metric-card">
<span>回收比例</span>
<strong>{{ formatRatio(selectedListing) }}</strong>
</div>
<div class="metric-card">
<span>回收租金</span>
<strong>{{ money(selectedListing.price) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
</div>
<div class="metric-card">
<span>每日损耗</span>
<strong>{{ dailyLossText(selectedListing) }}</strong>
</div>
</section>
<section class="review-grid">
<div class="review-panel">
<div class="panel-title">
<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>
</dl>
</div>
<div class="review-panel">
<div class="panel-title">
<h3>上传信息</h3>
<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>
</dl>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>库存资产</h3>
<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>
<div v-if="selectedResources.length" class="resource-table">
<div v-for="resource in selectedResources" :key="resource.key">
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>{{ resource.mode }} · {{ resource.price }}</em>
</div>
</div>
<div class="skin-list">
<el-tag v-for="skin in selectedSkins" :key="skin" type="success" effect="plain">{{ skin }}</el-tag>
<span v-if="!selectedSkins.length">暂无皮肤数据</span>
</div>
</section>
<section class="review-panel">
<div class="panel-title">
<h3>账号截图</h3>
<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)">
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<div v-else class="empty-warning">
<el-icon><WarningFilled /></el-icon>
<span>暂无截图</span>
</div>
</section>
</main>
<main v-else class="review-detail empty-detail">
<el-empty description="暂无待审核商品" />
</main>
</div>
<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>
<div class="reject-reasons">
<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="填写拒绝原因,号主会在通知中看到审核结果" />
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
</template>
</el-dialog>
<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)">
<img :src="previewURLs[url] || url" alt="账号截图" />
<span v-if="isDefaultScreenshot(url)">默认图片</span>
</button>
</div>
<p v-else>暂无截图</p>
</div>
<template #footer>
<el-button @click="evidenceListing = null">关闭</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.review-page {
min-height: calc(100vh - 128px);
}
.review-header {
margin-bottom: 18px;
}
.review-summary {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.review-summary div {
min-width: 0;
border-radius: 8px;
background: #ffffff;
padding: 14px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.review-summary span,
.queue-footer span,
.panel-title span,
.inventory-grid span,
.resource-table span {
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.review-summary strong {
display: block;
margin-top: 5px;
color: #1b2559;
font-size: 24px;
font-weight: 800;
}
.review-workbench {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.review-queue,
.review-detail,
.review-panel,
.review-hero-panel {
min-width: 0;
}
.review-queue {
position: sticky;
top: 16px;
border-radius: 8px;
background: #ffffff;
padding: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.queue-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 10px;
margin-bottom: 12px;
}
.queue-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 300px);
overflow: auto;
padding-right: 2px;
}
.queue-item {
width: 100%;
min-height: 112px;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #ffffff;
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.queue-item:hover,
.queue-item.active {
border-color: #4f7cff;
background: #f8faff;
box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1);
}
.queue-title-row,
.queue-footer,
.panel-title,
.hero-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.queue-title-row strong {
min-width: 0;
color: #1b2559;
font-size: 14px;
font-weight: 800;
line-height: 1.4;
}
.queue-title-row span {
flex-shrink: 0;
color: #4f7cff;
font-weight: 800;
}
.queue-meta,
.risk-strip,
.skin-list,
.reject-reasons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.queue-meta {
margin: 10px 0;
}
.queue-meta span {
border-radius: 6px;
background: #f0f2f5;
padding: 4px 7px;
color: #4b5563;
font-size: 12px;
font-weight: 700;
}
.queue-footer {
align-items: center;
}
.review-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.review-hero-panel,
.review-panel {
border-radius: 8px;
background: #ffffff;
padding: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hero-title h2,
.panel-title h3 {
margin: 0;
color: #1b2559;
font-size: 18px;
font-weight: 800;
line-height: 1.35;
}
.hero-title p {
margin: 0 0 6px;
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.risk-strip {
margin-top: 14px;
}
.review-metrics {
grid-template-columns: repeat(5, minmax(132px, 1fr));
gap: 12px;
margin-bottom: 0;
}
.review-metrics .metric-card {
border-radius: 8px;
padding: 16px;
}
.review-metrics .metric-card strong {
font-size: 20px;
}
.review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.panel-title {
align-items: center;
margin-bottom: 14px;
}
.detail-list {
display: grid;
gap: 10px;
margin: 0;
}
.detail-list div {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.detail-list dt {
color: #8f9bba;
font-size: 12px;
font-weight: 700;
}
.detail-list dd {
min-width: 0;
margin: 0;
color: #1f2937;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
}
.inventory-grid {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.inventory-grid div {
border-radius: 8px;
background: #f8f9fe;
padding: 12px;
}
.inventory-grid strong {
display: block;
margin-top: 4px;
color: #1b2559;
font-size: 22px;
font-weight: 900;
}
.resource-table {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.resource-table div {
display: grid;
grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr);
gap: 10px;
align-items: center;
border-bottom: 1px solid #f0f2f5;
padding-bottom: 8px;
}
.resource-table strong {
color: #1f2937;
font-size: 13px;
font-weight: 800;
}
.resource-table em {
color: #8f9bba;
font-size: 12px;
font-style: normal;
font-weight: 600;
}
.skin-list {
align-items: center;
}
.skin-list > span {
color: #8f9bba;
font-size: 13px;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.screenshot-tile {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #f8f9fe;
padding: 0;
cursor: pointer;
}
.screenshot-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.screenshot-tile span {
position: absolute;
top: 8px;
left: 8px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.95);
padding: 3px 7px;
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.empty-warning {
display: flex;
align-items: center;
gap: 8px;
min-height: 84px;
border-radius: 8px;
background: #fff7ed;
padding: 16px;
color: #c2410c;
font-weight: 700;
}
.dialog-screenshots {
max-height: 540px;
overflow: auto;
}
.reject-dialog p {
margin-top: 0;
}
.reject-reasons {
margin: 12px 0;
}
.empty-detail {
min-height: 420px;
display: grid;
place-items: center;
border-radius: 8px;
background: #ffffff;
}
@media (max-width: 1280px) {
.review-workbench {
grid-template-columns: 1fr;
}
.review-queue {
position: static;
}
.queue-list {
max-height: none;
}
}
@media (max-width: 900px) {
.review-summary,
.review-metrics,
.review-grid {
grid-template-columns: 1fr;
}
.hero-title {
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
.queue-toolbar {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,468 @@
<script setup lang="ts">
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 '@/api/listings'
import { useAdminTable } from '@/composables/useAdminTable'
import { useMoney } from '@/composables/useMoney'
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatListingCode,
formatRatio,
getCoinWan,
getDailyLoss,
getResourceQuantity,
getSkinGroup,
} from '@/utils/listingDisplay'
const money = useMoney()
const filters = reactive<AdminListingQuery>({
owner_id: '',
status: '',
review_status: '',
})
const pageSize = ref(10)
const currentPage = ref(1)
const { loading, data: listingPage, load: loadListings } = useAdminTable<AdminListingPage>({
fetchFn: () =>
fetchAdminListings({
...filters,
page: currentPage.value,
page_size: pageSize.value,
}),
initialData: {
items: [],
total: 0,
page: 1,
page_size: 10,
},
})
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 totalPages = computed(() => Math.max(1, Math.ceil(totalListings.value / pageSize.value)))
const screenshotColumns = [
{ label: '商品编码', width: 88, read: (row: Listing) => formatListingCode(row) },
{ label: '地区', width: 92, read: (row: Listing) => regionText(row) },
{ label: '上号方式', width: 78, read: (row: Listing) => row.login_platform || '-' },
{ label: '哈夫币(M)', width: 86, read: (row: Listing) => listingCoinM(row) },
{ label: '保险', width: 56, read: (row: Listing) => assetText(row, 'season_insurance') },
{ label: '体力(级)', width: 70, read: (row: Listing) => levelText(row, 'stamina_level') },
{ label: '负重(级)', width: 70, read: (row: Listing) => levelText(row, 'load_level') },
{ label: '账号等级', width: 76, read: (row: Listing) => assetText(row, 'fire_level') },
{ label: '绝密KD', width: 66, read: (row: Listing) => assetText(row, 'secret_kd') },
{ label: 'awm子弹', width: 74, read: (row: Listing) => resourceText(row, 'awmAmmo') },
{ 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: 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)}` },
] as const
async function queryListings() {
currentPage.value = 1
await loadListings()
}
function resetFilters() {
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
void queryListings()
}
async function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
await loadListings()
}
async function prevPage() {
currentPage.value = Math.max(1, currentPage.value - 1)
await loadListings()
}
async function nextPage() {
currentPage.value = Math.min(totalPages.value, currentPage.value + 1)
await loadListings()
}
async function copyTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
ElMessage.warning('当前浏览器不支持直接复制图片,请使用下载截图')
return
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
ElMessage.success('表格截图已复制到剪贴板')
} catch (error) {
ElMessage.error(readScreenshotError(error, '复制截图失败'))
}
}
async function downloadTableScreenshot() {
try {
const blob = await createTableScreenshotBlob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `商品管理表格-${formatFileTime(new Date())}.png`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('表格截图已下载')
} catch (error) {
ElMessage.error(readScreenshotError(error, '下载截图失败'))
}
}
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'))
if (!blob) throw new Error('截图生成失败')
return blob
}
function renderTableScreenshotCanvas(rows: Listing[]) {
const ratio = window.devicePixelRatio || 1
const paddingX = 24
const paddingY = 20
const titleHeight = 44
const headerHeight = 34
const lineHeight = 18
const cellPaddingX = 8
const cellPaddingY = 10
const tableWidth = screenshotColumns.reduce((sum, column) => sum + column.width, 0)
const canvasWidth = tableWidth + paddingX * 2
const measureCanvas = document.createElement('canvas')
const measureCtx = measureCanvas.getContext('2d')
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 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 canvas = document.createElement('canvas')
canvas.width = Math.round(canvasWidth * ratio)
canvas.height = Math.round(canvasHeight * ratio)
canvas.style.width = `${canvasWidth}px`
canvas.style.height = `${canvasHeight}px`
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('截图画布初始化失败')
ctx.scale(ratio, ratio)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
ctx.fillStyle = '#1b2559'
ctx.font = '700 18px Arial, "Microsoft YaHei", sans-serif'
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)
let y = paddingY + titleHeight
drawRect(ctx, paddingX, y, tableWidth, headerHeight, '#f8f9fe')
ctx.font = '700 12px Arial, "Microsoft YaHei", sans-serif'
ctx.fillStyle = '#8f9bba'
let x = paddingX
for (const column of screenshotColumns) {
drawCellText(ctx, [column.label], x + cellPaddingX, y + 22, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + headerHeight, paddingX + tableWidth, y + headerHeight)
y += headerHeight
ctx.font = '13px Arial, "Microsoft YaHei", sans-serif'
rows.forEach((row, rowIndex) => {
const rowHeight = rowHeights[rowIndex] ?? 44
drawRect(ctx, paddingX, y, tableWidth, rowHeight, rowIndex % 2 === 0 ? '#ffffff' : '#fbfcff')
x = paddingX
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'
drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight)
x += column.width
}
drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight)
y += rowHeight
})
return canvas
}
function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
const paragraphs = String(text || '-').split('\n')
const lines: string[] = []
for (const paragraph of paragraphs) {
let line = ''
for (const char of paragraph) {
const nextLine = line + char
if (line && ctx.measureText(nextLine).width > maxWidth) {
lines.push(line)
line = char
} else {
line = nextLine
}
}
lines.push(line || '-')
}
return lines
}
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) {
ctx.fillStyle = color
ctx.fillRect(x, y, width, height)
}
function drawLine(ctx: CanvasRenderingContext2D, startX: number, startY: number, endX: number, endY: number) {
ctx.strokeStyle = '#e6eaf2'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
}
function formatFileTime(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`
}
function readScreenshotError(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback
}
function listingPrice(row: Listing) {
return money(row.price)
}
function listingCoinM(row: Listing) {
return formatHafCoinM(getCoinWan(row))
}
function assetText(row: Listing, key: string) {
const value = row.asset_summary?.[key]
if (typeof value === 'number') return formatQuantity(value)
if (typeof value === 'string' && value.trim()) return value.trim()
return '-'
}
function levelText(row: Listing, key: string) {
const value = assetText(row, key)
return value === '-' ? value : value.replace(/级$/, '')
}
function regionText(row: Listing) {
const regions = assetRegions(row)
return regions.length ? regions.join('、') : '-'
}
function resourceText(row: Listing, key: string) {
const quantity = getResourceQuantity(row, key)
return quantity > 0 ? formatQuantity(quantity) : '-'
}
function skinGroupText(row: Listing, groupKey: string) {
const skins = getSkinGroup(row, groupKey)
return skins.length ? skins.join('、') : '-'
}
function characterAndWeaponSkinText(row: Listing) {
const groups = [
{ label: '红皮', key: 'operatorRed' },
{ label: '金皮', key: 'operatorGold' },
{ label: '武器', key: 'weapon' },
]
const parts = groups
.map((group) => {
const names = getSkinGroup(row, group.key)
return names.length ? `${group.label}${names.join('、')}` : ''
})
.filter(Boolean)
return parts.length ? parts.join(' / ') : '-'
}
function rentAndDepositText(row: Listing) {
return `${listingPrice(row)}/${money(row.deposit_amount)}`
}
function estimateTitle(row: Listing) {
return `按哈夫币 ${listingCoinM(row)}、日耗 ${getDailyLoss(row)} 估算`
}
function formatQuantity(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
</script>
<template>
<section class="page admin-listings-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Listings</p>
<h1>商品管理</h1>
<p>查看商品编码地区上号方式账号资产租金押金比例和预计租期</p>
</div>
</div>
<div class="listing-control-panel">
<div class="listing-metric-strip">
<span>结果 <strong>{{ totalListings }}</strong></span>
<span>本页 <strong>{{ listings.length }}</strong></span>
<span>已上架 <strong>{{ publishedCount }}</strong></span>
<span>已锁定 <strong>{{ rentedCount }}</strong></span>
<span>待审核 <strong>{{ pendingCount }}</strong></span>
</div>
<el-form class="listing-filter-bar" label-position="top">
<el-form-item label="号主 ID">
<el-input v-model="filters.owner_id" clearable placeholder="按号主筛选" />
</el-form-item>
<el-form-item label="商品状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="草稿" value="draft" />
<el-option label="已上架" value="published" />
<el-option label="已锁定" value="rented" />
<el-option label="已下架" value="offline" />
<el-option label="异常" value="abnormal" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="filters.review_status" clearable placeholder="全部审核状态" class="full-control">
<el-option label="未提交" value="none" />
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
</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 :disabled="!listings.length" @click="copyTableScreenshot">复制截图</el-button>
<el-button :disabled="!listings.length" @click="downloadTableScreenshot">下载截图</el-button>
</div>
</div>
<el-table
v-loading="loading"
class="table-panel admin-listings-table"
:data="listings"
row-key="id"
stripe
empty-text="暂无商品"
>
<el-table-column label="商品编码" width="88">
<template #default="{ row }">
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
</template>
</el-table-column>
<el-table-column label="地区" width="86">
<template #default="{ row }">{{ regionText(row) }}</template>
</el-table-column>
<el-table-column prop="login_platform" label="上号方式" width="78" />
<el-table-column label="哈夫币(M)" width="82">
<template #default="{ row }">{{ listingCoinM(row) }}</template>
</el-table-column>
<el-table-column label="保险" width="54">
<template #default="{ row }">{{ assetText(row, 'season_insurance') }}</template>
</el-table-column>
<el-table-column label="体力(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'stamina_level') }}</template>
</el-table-column>
<el-table-column label="负重(级)" width="68">
<template #default="{ row }">{{ levelText(row, 'load_level') }}</template>
</el-table-column>
<el-table-column label="账号等级" width="74">
<template #default="{ row }">{{ assetText(row, 'fire_level') }}</template>
</el-table-column>
<el-table-column label="绝密KD" width="66">
<template #default="{ row }">{{ assetText(row, 'secret_kd') }}</template>
</el-table-column>
<el-table-column label="awm子弹" width="72">
<template #default="{ row }">{{ resourceText(row, 'awmAmmo') }}</template>
</el-table-column>
<el-table-column label="六头" width="48">
<template #default="{ row }">{{ resourceText(row, 'helmet6') }}</template>
</el-table-column>
<el-table-column label="六甲" width="48">
<template #default="{ row }">{{ resourceText(row, 'armor6') }}</template>
</el-table-column>
<el-table-column label="特殊刀皮" width="104">
<template #default="{ row }">
{{ skinGroupText(row, 'melee') }}
</template>
</el-table-column>
<el-table-column label="人物红皮/人物金皮/武器皮肤" min-width="226">
<template #default="{ row }">
{{ characterAndWeaponSkinText(row) }}
</template>
</el-table-column>
<el-table-column label="租金/押金" width="90">
<template #default="{ row }">{{ rentAndDepositText(row) }}</template>
</el-table-column>
<el-table-column label="比例" width="62">
<template #default="{ row }">{{ formatRatio(row) }}</template>
</el-table-column>
<el-table-column label="租期" width="82">
<template #default="{ row }">
<span :title="estimateTitle(row)">{{ formatEstimatedRentalDuration(row) }}</span>
<span class="table-subtext">日耗 {{ getDailyLoss(row) }}</span>
</template>
</el-table-column>
<el-table-column label="详情" width="60">
<template #default="{ row }">
<RouterLink :to="`/admin/listings/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<span class="pagination-summary">当前页 {{ listings.length }} {{ totalListings }} </span>
<div class="pagination-controls">
<span class="pagination-size-label">每页</span>
<el-select
:model-value="pageSize"
class="page-size-select"
size="small"
@update:model-value="handlePageSizeChange"
>
<el-option :value="10" label="10条" />
<el-option :value="20" label="20条" />
<el-option :value="50" label="50条" />
<el-option :value="100" label="100条" />
</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>
</div>
</div>
</section>
</template>
@@ -0,0 +1,434 @@
<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 { fetchAdminCaptcha, type AdminCaptcha } from "@/api/adminAuth";
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 form = reactive({
username: "admin",
password: "admin123456",
captchaCode: "",
});
onMounted(loadCaptcha);
async function loadCaptcha() {
captchaLoading.value = true;
try {
captcha.value = await fetchAdminCaptcha();
form.captchaCode = "";
} catch (error) {
ElMessage.error(readError(error, "验证码加载失败"));
} finally {
captchaLoading.value = false;
}
}
async function handleLogin() {
loading.value = true;
try {
await adminSession.login(
form.username,
form.password,
captcha.value?.captcha_id || "",
form.captchaCode
);
ElMessage.success("后台登录成功");
await router.push("/admin/dashboard");
} catch (error) {
ElMessage.error(readError(error, "后台登录失败"));
await loadCaptcha();
} finally {
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;
}
return fallback;
}
</script>
<template>
<section class="admin-login-shell">
<div class="admin-login-glow" aria-hidden="true" />
<div class="admin-login-glow secondary" aria-hidden="true" />
<div class="admin-login-card">
<div class="admin-login-left">
<div class="brand-lockup">
<div class="brand-logo">H</div>
<strong>大锤商行 - 后台管理</strong>
</div>
<div class="admin-login-hero">
<h2>高效管理<br />从容掌控</h2>
<p>统一的后台管理系统助您轻松处理订单用户与运营数据</p>
</div>
<div class="admin-login-footer">
<span>© 哈夫币租号平台</span>
</div>
</div>
<div class="admin-login-right">
<div class="admin-login-header">
<p class="eyebrow">Admin Login</p>
<h1>管理员登录</h1>
</div>
<el-form class="admin-form" label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="form.username"
placeholder="请输入管理员用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入管理员密码"
size="large"
:prefix-icon="Lock"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="验证码">
<div class="admin-captcha-row">
<el-input
v-model="form.captchaCode"
maxlength="4"
placeholder="请输入验证码"
size="large"
@keyup.enter="handleLogin"
/>
<button
class="captcha-image-button"
type="button"
:disabled="captchaLoading"
@click="loadCaptcha"
>
<img v-if="captcha" :src="captcha.image" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</el-form-item>
<el-button
class="admin-login-btn"
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
>
登录后台
</el-button>
</el-form>
<RouterLink class="back-home" to="/"> 返回首页</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.admin-login-shell {
position: relative;
display: grid;
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%
),
linear-gradient(180deg, #0b1120 0%, #0f172a 60%, #111827 100%);
padding: 24px;
}
.admin-login-glow {
position: absolute;
top: -10%;
left: -10%;
width: 50vw;
height: 50vw;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(20, 119, 255, 0.22),
transparent 65%
);
filter: blur(80px);
pointer-events: none;
}
.admin-login-glow.secondary {
top: auto;
left: auto;
right: -10%;
bottom: -10%;
background: radial-gradient(
circle,
rgba(109, 40, 217, 0.18),
transparent 65%
);
}
.admin-login-card {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 420px;
width: min(960px, 100%);
min-height: 560px;
border: 1px solid rgba(255, 255, 255, 0.06);
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),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
/* ========== 左侧品牌区 ========== */
.admin-login-left {
display: flex;
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%
),
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));
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, #1477ff, #2f8dff);
box-shadow: 0 8px 24px rgba(20, 119, 255, 0.25);
color: #fff;
font-size: 15px;
font-weight: 900;
}
.brand-lockup strong {
color: #f8fafc;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.5px;
}
.admin-login-hero h2 {
margin: 0 0 14px;
color: #f1f5f9;
font-size: 28px;
font-weight: 800;
line-height: 1.25;
}
.admin-login-hero p {
margin: 0;
color: #94a3b8;
font-size: 14px;
line-height: 1.7;
max-width: 320px;
}
.admin-login-footer span {
color: #475569;
font-size: 12px;
}
/* ========== 右侧表单区 ========== */
.admin-login-right {
display: flex;
flex-direction: column;
justify-content: center;
padding: 36px 32px;
background: rgba(255, 255, 255, 0.03);
}
.admin-login-header {
margin-bottom: 24px;
}
.admin-login-header .eyebrow {
margin: 0 0 8px;
color: #1477ff;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.admin-login-header h1 {
margin: 0;
color: #f8fafc;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
}
.admin-form :deep(.el-form-item__label) {
color: #cbd5e1;
font-size: 13px;
font-weight: 600;
padding-bottom: 6px;
}
.admin-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.04);
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;
}
.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,
0 0 0 3px rgba(20, 119, 255, 0.08);
}
.admin-form :deep(.el-input__inner) {
color: #f1f5f9;
font-size: 14px;
height: 44px;
}
.admin-form :deep(.el-input__inner::placeholder) {
color: #64748b;
}
.admin-form :deep(.el-input__prefix-inner) {
color: #64748b;
}
.admin-captcha-row {
display: grid;
grid-template-columns: 1fr 132px;
gap: 10px;
width: 100%;
}
.captcha-image-button {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
padding: 0;
cursor: pointer;
overflow: hidden;
transition: background 0.2s, border-color 0.2s;
}
.captcha-image-button:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.14);
}
.captcha-image-button:disabled {
cursor: wait;
opacity: 0.6;
}
.captcha-image-button img {
display: block;
width: 132px;
height: 44px;
}
.captcha-image-button span {
color: #94a3b8;
font-size: 13px;
}
.admin-login-btn {
width: 100%;
height: 46px;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
margin-top: 8px;
letter-spacing: 0.5px;
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;
}
.admin-login-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 36px rgba(20, 119, 255, 0.32);
}
.back-home {
display: block;
margin-top: 18px;
text-align: center;
color: #64748b;
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
}
.back-home:hover {
color: #94a3b8;
}
/* ========== 响应式 ========== */
@media (max-width: 860px) {
.admin-login-card {
grid-template-columns: 1fr;
max-width: 420px;
}
.admin-login-left {
display: none;
}
.admin-login-right {
padding: 32px 28px;
}
}
@media (max-width: 480px) {
.admin-login-shell {
padding: 16px;
}
.admin-login-right {
padding: 24px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-login-btn {
transition: none;
}
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/api/adminMgr'
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time'
import AdminUserDialog from './components/AdminUserDialog.vue'
import AssignRolesDialog from './components/AssignRolesDialog.vue'
const showDialog = ref(false)
const editingAdmin = ref<AdminMgrUser | null>(null)
const showRolesDialog = ref(false)
const rolesAdmin = ref<AdminMgrUser | null>(null)
const showPasswordDialog = ref(false)
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>({
fetchFn: fetchAdminMgrUsers,
})
function openCreate() {
editingAdmin.value = null
showDialog.value = true
}
function openEdit(row: AdminMgrUser) {
editingAdmin.value = row
showDialog.value = true
}
function openRoles(row: AdminMgrUser) {
rolesAdmin.value = row
showRolesDialog.value = true
}
function openPassword(row: AdminMgrUser) {
passwordAdmin.value = row
passwordForm.value = { old_password: '', new_password: '' }
showPasswordDialog.value = true
}
async function handleDelete(row: AdminMgrUser) {
try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除')
await loadAdmins()
} catch {
// 用户取消
}
}
async function handleChangePassword() {
if (!passwordAdmin.value) return
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
passwordSubmitting.value = true
try {
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value)
ElMessage.success('密码已修改')
showPasswordDialog.value = false
} catch (error) {
ElMessage.error(readError(error, '修改失败'))
} finally {
passwordSubmitting.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
}
return fallback
}
const statusLabel: Record<string, string> = {
active: '启用',
disabled: '禁用',
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Admin Users</p>
<h1>管理员管理</h1>
<p>管理后台管理员账号分配角色和权限</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadAdmins">刷新</el-button>
<el-button type="primary" @click="openCreate">新建管理员</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="admins">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column label="角色" min-width="200">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role.id"
size="small"
style="margin-right: 4px; margin-bottom: 2px"
>
{{ role.name }}
</el-tag>
<span v-if="!row.roles?.length" style="color: #8f9bba">未分配</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ statusLabel[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<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 }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openRoles(row)">角色</el-button>
<el-button size="small" @click="openPassword(row)">密码</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadAdmins"
@size-change="handleSizeChange"
/>
</div>
<!-- 新建/编辑对话框 -->
<AdminUserDialog
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<!-- 角色分配对话框 -->
<AssignRolesDialog
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<!-- 修改密码对话框 -->
<el-dialog
:model-value="showPasswordDialog"
:title="`修改密码 - ${passwordAdmin?.username || ''}`"
width="460px"
@update:model-value="showPasswordDialog = $event"
>
<div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<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-form-item>
</div>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword">确认修改</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
@@ -0,0 +1,202 @@
<script setup lang="ts">
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 '@/api/orders'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const loading = ref(false)
const submitting = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const actionType = ref<'close' | 'abnormal' | ''>('')
const reason = ref('')
const refundStatus = ref<RefundStatus | null>(null)
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))
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
await loadRefundStatus()
} finally {
loading.value = false
}
}
async function loadRefundStatus() {
try {
refundStatus.value = await adminRefundStatus(Number(route.params.id))
} catch {
refundStatus.value = null
}
}
function openAction(type: 'close' | 'abnormal') {
actionType.value = type
reason.value = ''
}
async function submitAction() {
if (!order.value || !actionType.value) return
submitting.value = true
try {
if (actionType.value === 'close') {
await adminCloseOrder(order.value.id, reason.value)
ElMessage.success('订单已关闭')
} else {
await adminMarkOrderAbnormal(order.value.id, reason.value)
ElMessage.success('订单已标记异常')
}
actionType.value = ''
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.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
}
return fallback
}
function orderRentedAt() {
return order.value?.rented_at
}
function orderEstimatedEndAt() {
if (!order.value) return undefined
const rentedAt = orderRentedAt()
const durationHours = Number(order.value.estimated_duration_hours || 0)
if (!rentedAt || durationHours <= 0) return undefined
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
}
function money(value: unknown) {
return Math.round(Number(value || 0))
}
async function handleRefund() {
if (!order.value) return
refunding.value = true
try {
refundStatus.value = await adminRefundOrder(order.value.id)
ElMessage.success('退款已发起')
await loadOrder()
} catch (error) {
ElMessage.error(readError(error, '退款失败'))
} finally {
refunding.value = false
}
}
function refundStatusLabel(status: string) {
const map: Record<string, string> = {
pending: '待退款',
refunded: '已退款',
failed: '退款失败',
}
return map[status] || status || '未退款'
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="order" class="page-header-row">
<div class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
</div>
<div class="toolbar-actions">
<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">
{{ refundStatus?.refund_status === 'refunded' ? '已退款' : '人工退款' }}
</el-button>
</div>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ orderStatusLabel(order.status) }}</strong>
</div>
<div class="metric-card">
<span>交接状态</span>
<strong>{{ handoffStatusLabel(order.handoff_status) }}</strong>
</div>
<div class="metric-card">
<span>订单金额</span>
<strong>¥{{ money(order.rent_amount) }}</strong>
</div>
<div class="metric-card">
<span>平台费用</span>
<strong>¥{{ money(order.platform_fee) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>¥{{ money(order.deposit_amount) }}</strong>
</div>
<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>
</div>
</div>
<div v-if="order" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>用户信息</h2>
<p>租客{{ order.renter_phone || order.renter_id }}</p>
<p>号主{{ order.owner_phone || order.owner_id }}</p>
<p>开始{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
<p>预计截止{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
</div>
<div class="order-panel dashboard-panel">
<h2>交接记录</h2>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
<strong>{{ record.type }}</strong>
<p>{{ record.content }}</p>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</div>
</div>
<div v-if="order" class="order-panel dashboard-panel code-panel">
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
</div>
<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="填写客服操作原因,会写入审计日志并通知双方" />
</div>
<template #footer>
<el-button @click="actionType = ''">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { fetchAdminOrders, type Order } from '@/api/orders'
import { useAdminTable } from '@/composables/useAdminTable'
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const status = ref('')
const { loading, data: orders, load: loadOrders } = useAdminTable<Order[]>({
fetchFn: fetchAdminOrders,
initialData: [],
})
const filteredOrders = computed(() => {
if (!status.value) return orders.value
return orders.value.filter((item) => item.status === status.value)
})
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Orders</p>
<h1>订单管理</h1>
<p>查看全量订单交接状态金额和结算状态</p>
</div>
<div class="toolbar-actions">
<el-select v-model="status" clearable placeholder="订单状态" style="width: 180px">
<el-option label="待支付" value="pending_payment" />
<el-option label="待交接" value="pending_handoff" />
<el-option label="使用中" value="renting" />
<el-option label="逾期中" value="overdue" />
<el-option label="待结账确认" value="pending_return_confirm" />
<el-option label="待号主确认结账" value="pending_checkout_confirm" />
<el-option label="待租客确认修正" value="pending_checkout_accept" />
<el-option label="结账争议中" value="checkout_disputing" />
<el-option label="申诉中" value="disputing" />
<el-option label="异常" value="abnormal" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button @click="loadOrders">刷新</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
<el-table-column prop="order_no" label="订单号" min-width="230" />
<el-table-column prop="title" label="账号" min-width="170" />
<el-table-column prop="renter_phone" label="租客" min-width="130" />
<el-table-column prop="owner_phone" label="号主" min-width="130" />
<el-table-column label="状态" width="140">
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="交接" width="180">
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
</el-table-column>
<el-table-column label="结算" width="120">
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
</el-table-column>
<el-table-column prop="rent_amount" label="订单金额" width="100" />
<el-table-column prop="deposit_amount" label="押金" width="100" />
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</section>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchRoles, deleteRole, type Role } from '@/api/adminRoles'
import { useAdminTable } from '@/composables/useAdminTable'
import AssignPermissionsDialog from './components/AssignPermissionsDialog.vue'
import RoleDialog from './components/RoleDialog.vue'
const showDialog = ref(false)
const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
fetchFn: fetchRoles,
})
function openCreate() {
editingRole.value = null
showDialog.value = true
}
function openEdit(row: Role) {
editingRole.value = row
showDialog.value = true
}
function openPerms(row: Role) {
permsRole.value = row
showPermsDialog.value = true
}
async function handleDelete(row: Role) {
try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteRole(row.id)
ElMessage.success('角色已删除')
await loadRoles()
} catch {
// 用户取消
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Roles</p>
<h1>角色管理</h1>
<p>管理系统角色及其权限配置</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadRoles">刷新</el-button>
<el-button type="primary" @click="openCreate">新建角色</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="roles">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="code" label="编码" min-width="120" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="perm_count" label="权限数" width="90" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openPerms(row)">权限</el-button>
<el-button
v-if="row.code !== 'super_admin'"
size="small"
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑对话框 -->
<RoleDialog
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<!-- 权限分配对话框 -->
<AssignPermissionsDialog
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
</style>
@@ -0,0 +1,406 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
emptyListingSalePriceConfig,
emptyListingPublishOptions,
mergeListingSalePriceConfig,
mergeListingPublishOptions,
type ListingPublishOptions,
type PublishSalePriceConfig,
} from '@/api/listingOptions'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
mergeHomeConfig,
type HomeBannerSlide,
} from '@/api/homeConfig'
import { fetchSystemConfigs, type SystemConfig } from '@/api/systemConfigs'
import { formatDateTime } from '@/utils/time'
import { safeParseJSON } from '@/utils/json'
import { formatSystemConfigSelectValue } from '@/utils/systemConfigOptions'
// 导入重构后的 Dialog 子组件
import PublishOptionsDialog from './components/PublishOptionsDialog.vue'
import SalePriceDialog from './components/SalePriceDialog.vue'
import HomeAnnouncementsDialog from './components/HomeAnnouncementsDialog.vue'
import HomeBannersDialog from './components/HomeBannersDialog.vue'
import GeneralConfigDialog from './components/GeneralConfigDialog.vue'
import AutoWelcomeConfig from './components/AutoWelcomeConfig.vue'
const loading = ref(false)
const configs = ref<SystemConfig[]>([])
const currentEditingConfig = ref<SystemConfig | null>(null)
// 各种子 Dialog 的可见性控制
const publishVisible = ref(false)
const salePriceVisible = ref(false)
const announcementsVisible = ref(false)
const bannersVisible = 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 regularConfigs = computed(() =>
configs.value.filter(
(item) =>
item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners',
),
)
// 只读计算属性,用于渲染卡片上的统计信息
const publishStats = computed(() => {
const options = safeParsePublishOptions(publishConfig.value?.value || '')
return {
baseCount:
options.server_options.length +
options.rank_options.length +
options.insurance_options.length +
options.level_options.length,
skinCount: options.skin_groups.reduce((sum, group) => sum + group.options.length, 0),
resourceCount: options.quantity_items.length,
screenshotCount: options.screenshot_slots.length,
regionCount: options.region_options.length,
}
})
const salePriceStats = computed(() => {
const config = safeParseSalePriceConfig(salePriceConfig.value?.value || '')
return {
fixedCount: config.fixed_markup_rules.length,
ratioCount: config.ratio_adjustment_rules.length,
}
})
const homeStats = computed(() => {
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '')
const banners = parseHomeBanners(homeBannersConfig.value?.value || '')
return {
announcementCount: announcements.length,
bannerCount: banners.length,
}
})
onMounted(loadConfigs)
async function loadConfigs() {
loading.value = true
try {
configs.value = await fetchSystemConfigs()
} finally {
loading.value = false
}
}
function openEdit(row: SystemConfig) {
currentEditingConfig.value = row
if (row.key === 'listing.publish_options') {
publishVisible.value = true
} else if (row.key === 'listing.sale_price_config') {
salePriceVisible.value = true
} else if (row.key === 'mobile.home_announcements') {
announcementsVisible.value = true
} else if (row.key === 'mobile.home_banners') {
bannersVisible.value = true
} else {
generalVisible.value = true
}
}
function safeParsePublishOptions(raw: string) {
const parsed = safeParseJSON(raw, emptyListingPublishOptions)
return cloneOptions(mergeListingPublishOptions(parsed))
}
function safeParseSalePriceConfig(raw: string) {
const parsed = safeParseJSON(raw, emptyListingSalePriceConfig)
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
}
function parseHomeAnnouncements(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeAnnouncements)
return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements
}
function parseHomeBanners(raw: string) {
const parsed = safeParseJSON(raw, defaultHomeBanners)
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
}
function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
}
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
}
function cloneHomeBanners(banners: HomeBannerSlide[]) {
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
}
function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) {
if (!row) return fallback
return formatDateTime(row.updated_at, fallback)
}
function formatConfigValue(row: SystemConfig) {
const trimmed = row.value.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return 'JSON 配置'
}
const selectedLabel = formatSystemConfigSelectValue(row.key, row.value)
if (selectedLabel) {
return selectedLabel
}
return row.value
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Configs</p>
<h1>系统配置</h1>
<p>管理交接超时归还超时短信限流最低押金和抽成比例</p>
</div>
<section v-if="publishConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Publish Options</p>
<h2>发布表单选项</h2>
<span>管理移动端发布页的区服段位皮肤额外消耗品截图材料和地区选项</span>
</div>
<el-button type="primary" @click="openEdit(publishConfig)">编辑发布选项</el-button>
</div>
<div class="publish-stat-grid">
<div class="publish-stat">
<strong>{{ publishStats.baseCount }}</strong>
<span>基础选项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.skinCount }}</strong>
<span>皮肤项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.resourceCount }}</strong>
<span>额外消耗品</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.screenshotCount }}</strong>
<span>截图材料</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.regionCount }}</strong>
<span>地区</span>
</div>
</div>
</section>
<section v-if="salePriceConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Sale Pricing</p>
<h2>内部出售定价规则</h2>
<span>管理出售专用比例计算规则仅用于平台内部定价计算不在发布页展示</span>
</div>
<el-button type="primary" @click="openEdit(salePriceConfig)">编辑出售规则</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ salePriceStats.fixedCount }}</strong>
<span>固定加价</span>
</div>
<div class="publish-stat">
<strong>{{ salePriceStats.ratioCount }}</strong>
<span>比例修正</span>
</div>
<div class="publish-stat">
<strong>内部</strong>
<span>不展示给发布用户</span>
</div>
<div class="publish-stat">
<strong>配置</strong>
<span>listing.sale_price_config</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(salePriceConfig, '未初始化') }}</span>
</div>
</div>
</section>
<section v-if="homeAnnouncementsConfig || homeBannersConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Home Content</p>
<h2>移动端首页运营配置</h2>
<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>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ homeStats.announcementCount }}</strong>
<span>公告条数</span>
</div>
<div class="publish-stat">
<strong>{{ homeStats.bannerCount }}</strong>
<span>轮播图</span>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>/api/mobile-home-config</span>
</div>
<div class="publish-stat">
<strong>公告</strong>
<span>{{ formatHomeConfigStatus(homeAnnouncementsConfig, '未初始化') }}</span>
</div>
<div class="publish-stat">
<strong>轮播</strong>
<span>{{ formatHomeConfigStatus(homeBannersConfig, '未初始化') }}</span>
</div>
</div>
</section>
<AutoWelcomeConfig />
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="formatConfigValue(row) === 'JSON 配置'" type="info">JSON 配置</el-tag>
<span v-else class="config-value">{{ formatConfigValue(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
<el-table-column prop="updated_by" label="更新人" width="100" />
<el-table-column label="更新时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 弹窗编辑器子组件 -->
<PublishOptionsDialog
v-if="publishConfig"
v-model="publishVisible"
:config="publishConfig"
@saved="loadConfigs"
/>
<SalePriceDialog
v-if="salePriceConfig"
v-model="salePriceVisible"
:config="salePriceConfig"
@saved="loadConfigs"
/>
<HomeAnnouncementsDialog
v-if="homeAnnouncementsConfig"
v-model="announcementsVisible"
:config="homeAnnouncementsConfig"
@saved="loadConfigs"
/>
<HomeBannersDialog
v-if="homeBannersConfig"
v-model="bannersVisible"
:config="homeBannersConfig"
@saved="loadConfigs"
/>
<GeneralConfigDialog
v-if="currentEditingConfig"
v-model="generalVisible"
:config="currentEditingConfig"
@saved="loadConfigs"
/>
</section>
</template>
<style scoped>
.publish-config-panel {
display: grid;
gap: 18px;
margin-bottom: 18px;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.publish-config-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.publish-config-main h2 {
margin: 4px 0 6px;
color: #111827;
font-size: 20px;
}
.publish-config-main span {
color: #6b7280;
font-size: 13px;
}
.publish-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.home-stat-grid .publish-stat strong {
font-size: 18px;
}
.publish-stat {
display: grid;
gap: 4px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
}
.publish-stat strong {
color: #1477ff;
font-size: 22px;
line-height: 1;
}
.publish-stat span,
.config-value {
color: #4b5563;
font-size: 13px;
}
@media (max-width: 1100px) {
.publish-stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
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>({
fetchFn: fetchAdminUsers,
})
function openFreeze(row: AdminUserItem) {
activeUser.value = row
freezeReason.value = ''
}
async function handleFreeze() {
if (!activeUser.value) return
submitting.value = true
try {
await freezeAdminUser(activeUser.value.id, freezeReason.value)
ElMessage.success('用户已冻结')
activeUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '冻结失败'))
} finally {
submitting.value = false
}
}
async function handleUnfreeze(row: AdminUserItem) {
submitting.value = true
try {
await unfreezeAdminUser(row.id)
ElMessage.success('用户已解冻')
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '解冻失败'))
} finally {
submitting.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
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户信息和状态处理冻结与解冻</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="users">
<el-table-column prop="id" label="用户ID" width="80" />
<el-table-column prop="nickname" label="昵称" min-width="130" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="注册时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</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>
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadUsers"
@size-change="handleSizeChange"
/>
</div>
<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="填写冻结原因,便于审计追踪" />
</div>
<template #footer>
<el-button @click="activeUser = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/api/adminWallet'
import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const ledger = ref<AdminWalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
user_id: '',
order_id: '',
biz_type: '',
})
const inAmount = computed(() =>
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),
)
onMounted(loadLedger)
async function loadLedger() {
loading.value = true
try {
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminWalletLedger(query)
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.user_id = ''
filters.order_id = ''
filters.biz_type = ''
currentPage.value = 1
void loadLedger()
}
function handleSizeChange() {
currentPage.value = 1
loadLedger()
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
}
function directionType(direction: string) {
if (direction === 'in') return 'success'
if (direction === 'out') return 'danger'
return 'warning'
}
function directionLabel(direction: string) {
return ledgerDirectionLabel(direction)
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Wallet Ledger</p>
<h1>资金流水</h1>
<p>查看订单金额押金冻结解冻退款和结算流水</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>入账合计</span>
<strong>{{ money(inAmount) }}</strong>
</div>
<div class="metric-card">
<span>出账合计</span>
<strong>{{ money(outAmount) }}</strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" />
</el-form-item>
<el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" />
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="订单冻结" value="order_freeze" />
<el-option label="订单取消释放" value="order_cancel_release" />
<el-option label="订单结算" value="order_settlement" />
<el-option label="押金退回" value="deposit_refund" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="ledger">
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
<el-table-column label="用户" min-width="130">
<template #default="{ row }">
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
</template>
</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>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="150" />
<el-table-column label="方向" width="90">
<template #default="{ row }">
<el-tag :type="directionType(row.direction)">{{ directionLabel(row.direction) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="110">
<template #default="{ row }">{{ money(Number(row.amount)) }}</template>
</el-table-column>
<el-table-column label="余额类型" width="110">
<template #default="{ row }">{{ balanceTypeLabel(row.balance_type) }}</template>
</el-table-column>
<el-table-column label="变化后余额" width="130">
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedger"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>