feat(admin): 优化商品审核工作台界面、截图凭证核验与按住速览体验

- 深度优化商品审核工作台:指标概览卡片、风险边条队列、标价管道联动、核心资产高密度展示、底部决策浮动 Dock、全套键盘快捷键
- 重构截图大图查看体验:移除旧版底部溢出的全局画廊,封装全屏居中弹窗 AdminListingReviewPreviewDialog,支持「按住速览(松手即关闭)」与「常驻弹窗(缩放/翻页/打码/原图)」双模交互
- 新增在线截图编辑器 ImageEditorDialog:支持平滑马赛克打码、自由裁剪与旋转保存
- 弹窗组件解耦:抽离审核拒绝、调价、全量核验、快捷键速查等子组件,严格控制单文件行数
- 后端支持:新增商品审核调整价格接口与配套单元测试
This commit is contained in:
yml2213
2026-09-07 13:29:01 +08:00
parent d7cd15c2f3
commit 3f86dd85ad
16 changed files with 5028 additions and 821 deletions
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Listing } from '@/features/listings'
import { formatListingCode, readAssetString } from '@/shared/utils/listingDisplay'
const props = defineProps<{
visible: boolean
listing: Listing | null
submitting: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'reject', payload: { listingId: number; reason: string }): void
}>()
const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
'截图与所填信息不符',
'缺少安全中心实机截图',
]
const rejectReason = ref('')
watch(
() => [props.visible, props.listing] as const,
([visible, listing]) => {
if (visible && listing) {
rejectReason.value = listing.review_reason || ''
const ban = readAssetString(listing, 'ban_record')
const hasBan = ban && ban !== '-' && !ban.includes('无')
const hasDefaultShot = listing.screenshot_urls?.some(url =>
url.includes('/api/listings/default-upload-screenshot')
)
if (hasBan && !rejectReason.value.includes('封禁记录')) {
toggleRejectReason('封禁记录需补充说明')
} else if (hasDefaultShot && !rejectReason.value.includes('默认截图')) {
toggleRejectReason('默认截图,需补充真实截图')
}
}
}
)
function toggleRejectReason(reason: string) {
const current = rejectReason.value.trim()
if (!current) {
rejectReason.value = reason
return
}
const parts = current
.split(/[;;]/)
.map(p => p.trim())
.filter(Boolean)
const idx = parts.indexOf(reason)
if (idx >= 0) {
parts.splice(idx, 1)
} else {
parts.push(reason)
}
rejectReason.value = parts.join(';')
}
function isReasonSelected(reason: string) {
const parts = rejectReason.value
.split(/[;;]/)
.map(p => p.trim())
.filter(Boolean)
return parts.includes(reason)
}
function handleConfirm() {
if (!props.listing) return
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写拒绝原因')
return
}
emit('reject', {
listingId: props.listing.id,
reason,
})
}
</script>
<template>
<el-dialog
:model-value="visible"
title="拒绝发布商品"
width="640px"
append-to-body
@update:model-value="emit('update:visible', $event)"
>
<div v-if="listing" class="dialog-body reject-dialog">
<div class="dialog-item-header">
<span class="dialog-code">#{{ formatListingCode(listing) }}</span>
<strong>{{ listing.title }}</strong>
</div>
<div class="reject-quick-tags">
<p class="quick-tags-title">快捷原因选择(点击可追加或取消):</p>
<div class="quick-tags-list">
<button
v-for="reason in rejectReasonOptions"
:key="reason"
type="button"
class="quick-reason-pill"
:class="{ selected: isReasonSelected(reason) }"
@click="toggleRejectReason(reason)"
>
<el-icon v-if="isReasonSelected(reason)"><Check /></el-icon>
{{ reason }}
</button>
</div>
</div>
<div class="reject-input-block">
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
maxlength="200"
show-word-limit
placeholder="请详细填写审核拒绝原因,将通过短信与站内信通知号主修改后重新提交..."
/>
</div>
<div class="reject-tip">
<el-icon><InfoFilled /></el-icon>
号主修改后可重新提交审核,原商品信息将进入已下架/待重新发布状态。
</div>
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button
type="danger"
:loading="submitting"
:disabled="!rejectReason.trim()"
@click="handleConfirm"
>
确认拒绝并通知号主
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.dialog-code {
padding: 2px 8px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 700;
font-family: monospace;
}
.reject-quick-tags {
margin-bottom: 14px;
}
.quick-tags-title {
margin: 0 0 8px;
color: #64748b;
font-size: 12px;
font-weight: 600;
}
.quick-tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.quick-reason-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
padding: 5px 10px;
color: #334155;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.quick-reason-pill:hover {
border-color: #2563eb;
color: #2563eb;
}
.quick-reason-pill.selected {
border-color: #2563eb;
background: #eff6ff;
color: #2563eb;
}
.reject-tip {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
color: #64748b;
font-size: 12px;
}
</style>