Files
hfb_sys/frontend/src/features/admin/components/AdminListingReviewRejectDialog.vue
T
yml2213 3f86dd85ad feat(admin): 优化商品审核工作台界面、截图凭证核验与按住速览体验
- 深度优化商品审核工作台:指标概览卡片、风险边条队列、标价管道联动、核心资产高密度展示、底部决策浮动 Dock、全套键盘快捷键
- 重构截图大图查看体验:移除旧版底部溢出的全局画廊,封装全屏居中弹窗 AdminListingReviewPreviewDialog,支持「按住速览(松手即关闭)」与「常驻弹窗(缩放/翻页/打码/原图)」双模交互
- 新增在线截图编辑器 ImageEditorDialog:支持平滑马赛克打码、自由裁剪与旋转保存
- 弹窗组件解耦:抽离审核拒绝、调价、全量核验、快捷键速查等子组件,严格控制单文件行数
- 后端支持:新增商品审核调整价格接口与配套单元测试
2026-09-07 13:29:01 +08:00

224 lines
5.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>