merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离

This commit is contained in:
yml2213
2026-09-07 17:37:31 +08:00
51 changed files with 11152 additions and 4950 deletions
@@ -195,6 +195,9 @@ export interface FinanceDisbursementQuery extends FinanceDateQuery {
date_type?: string
business_no?: string
keyword?: string
operator?: string
operator_name?: string
operator_id?: string
user_id?: string
page?: number
page_size?: number
@@ -309,6 +312,8 @@ export interface FinanceDetail {
export interface FinanceDateQuery {
start_date?: string
end_date?: string
start_time?: string
end_time?: string
}
export interface FinanceDetailQuery extends FinanceDateQuery {
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { EditPen, FullScreen, Pointer } from '@element-plus/icons-vue'
import type { Listing } from '@/features/listings'
import { formatListingCode } from '@/shared/utils/listingDisplay'
interface ReviewScreenshotItem {
url: string
label: string
categoryKey?: string
}
const props = defineProps<{
visible: boolean
listing: Listing | null
previewUrls: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'viewImage', payload: { images: ReviewScreenshotItem[]; index: number }): void
(e: 'peekImage', payload: { images: ReviewScreenshotItem[]; index: number }): void
(e: 'editScreenshot', payload: { shot: ReviewScreenshotItem; listing: Listing }): void
}>()
const standardSlots = [
{ key: 'coin', label: '纯币截图' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
{ key: 'skin', label: '皮肤截图' },
{ key: 'gameId', label: '游戏ID截图' },
{ key: 'totalAsset', label: '总资产截图' },
]
const evidenceCategory = ref('all')
watch(
() => props.visible,
visible => {
if (visible) {
evidenceCategory.value = 'all'
}
}
)
const evidenceReviewScreenshots = computed(() => getListingScreenshots(props.listing))
const filteredEvidenceScreenshots = computed(() => {
if (evidenceCategory.value === 'all') {
return evidenceReviewScreenshots.value
}
return evidenceReviewScreenshots.value.filter(s => s.categoryKey === evidenceCategory.value)
})
function isDefaultScreenshot(url: string) {
return url.includes('/api/listings/default-upload-screenshot')
}
function getListingScreenshots(item: Listing | null): ReviewScreenshotItem[] {
if (!item) return []
const groups = item.asset_summary?.screenshot_groups
if (typeof groups === 'object' && groups !== null) {
const grouped = standardSlots.flatMap(slot => {
const urls = (groups as Record<string, unknown>)[slot.key]
if (!Array.isArray(urls)) return []
const validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
categoryKey: slot.key,
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}))
})
if (grouped.length > 0) {
const groupedUrlSet = new Set(grouped.map(s => s.url))
const extraUrls = (item.screenshot_urls || []).filter(url => !groupedUrlSet.has(url))
const extras = extraUrls.map((url, index) => ({
categoryKey: 'extra',
label: `账号截图${grouped.length + index + 1}`,
url,
}))
return [...grouped, ...extras]
}
}
const defaultLabels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (item.screenshot_urls || []).map((url, index) => ({
categoryKey: standardSlots[index]?.key || 'extra',
label: defaultLabels[index] || `账号截图${index + 1}`,
url,
}))
}
function handleView(index: number) {
emit('viewImage', {
images: filteredEvidenceScreenshots.value,
index,
})
}
function handleEdit(shot: ReviewScreenshotItem) {
if (props.listing) {
emit('editScreenshot', { shot, listing: props.listing })
}
}
</script>
<template>
<el-dialog
:model-value="visible"
title="全量凭证截图核验"
width="900px"
append-to-body
@update:model-value="emit('update:visible', $event)"
>
<div v-if="listing" class="dialog-body evidence-dialog">
<div class="evidence-header-bar">
<div class="evidence-item-info">
<span class="evidence-code">#{{ formatListingCode(listing) }}</span>
<strong>{{ listing.title }}</strong>
</div>
<div class="evidence-tabs">
<el-radio-group v-model="evidenceCategory" size="small">
<el-radio-button label="all"
>全部 ({{ evidenceReviewScreenshots.length }})</el-radio-button
>
<el-radio-button label="coin">纯币</el-radio-button>
<el-radio-button label="tencentSecurity">安全中心</el-radio-button>
<el-radio-button label="skin">皮肤</el-radio-button>
<el-radio-button label="gameId">游戏ID</el-radio-button>
<el-radio-button label="totalAsset">总资产</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-if="filteredEvidenceScreenshots.length" class="screenshot-gallery-grid dialog-gallery">
<div
v-for="(shot, index) in filteredEvidenceScreenshots"
:key="shot.url"
class="screenshot-card"
@click="handleView(index)"
>
<div class="image-wrapper">
<img :src="previewUrls[shot.url] || shot.url" :alt="shot.label" loading="lazy" />
<span v-if="isDefaultScreenshot(shot.url)" class="default-badge">默认占位图</span>
<span class="category-tag">{{ shot.label }}</span>
<div class="image-overlay" @click.stop>
<el-button
size="small"
type="primary"
:icon="Pointer"
circle
title="按住速览(松手即关闭)"
@mousedown.stop.prevent="
emit('peekImage', { images: filteredEvidenceScreenshots, index })
"
@touchstart.stop.prevent="
emit('peekImage', { images: filteredEvidenceScreenshots, index })
"
/>
<el-button
size="small"
type="primary"
:icon="FullScreen"
circle
title="弹窗大图 (Esc关闭)"
@click="handleView(index)"
/>
<el-button
size="small"
type="warning"
:icon="EditPen"
circle
title="在线编辑打码"
@click="handleEdit(shot)"
/>
</div>
</div>
</div>
</div>
<el-empty v-else description="该分类下暂无截图凭证" :image-size="80" />
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.evidence-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
}
.evidence-item-info {
display: flex;
align-items: center;
gap: 8px;
}
.evidence-code {
padding: 2px 8px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 700;
font-family: monospace;
}
.dialog-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 14px;
max-height: 560px;
overflow-y: auto;
padding-right: 4px;
}
.screenshot-card {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e2e8f0;
background: #f8fafc;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.25s ease;
}
.screenshot-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
border-color: #93c5fd;
}
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.image-wrapper img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.default-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 7px;
border-radius: 4px;
background: rgba(217, 119, 6, 0.95);
color: #ffffff;
font-size: 11px;
font-weight: 700;
z-index: 2;
}
.category-tag {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(
to top,
rgba(15, 23, 42, 0.9) 0%,
rgba(15, 23, 42, 0.4) 70%,
transparent 100%
);
color: #ffffff;
font-size: 12px;
font-weight: 700;
text-align: center;
padding: 14px 6px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 2;
}
.image-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(2px);
opacity: 0;
transition: opacity 0.2s ease;
z-index: 3;
}
.screenshot-card:hover .image-overlay {
opacity: 1;
}
</style>
@@ -0,0 +1,655 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArrowLeft,
ArrowRight,
Close,
EditPen,
FullScreen,
Lock,
RefreshRight,
TopRight,
ZoomIn,
ZoomOut,
} from '@element-plus/icons-vue'
export interface PreviewScreenshotItem {
url: string
label: string
categoryKey?: string
}
const props = withDefaults(
defineProps<{
visible: boolean
images: PreviewScreenshotItem[]
initialIndex?: number
isPeekMode?: boolean
previewUrls?: Record<string, string>
}>(),
{
initialIndex: 0,
isPeekMode: false,
previewUrls: () => ({}),
}
)
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:isPeekMode', value: boolean): void
(e: 'edit', payload: { shot: PreviewScreenshotItem; index: number }): void
(e: 'openRaw', url: string): void
}>()
const currentIndex = ref(0)
const zoomScale = ref(1)
const rotationDeg = ref(0)
watch(
() => props.initialIndex,
newIdx => {
currentIndex.value = Math.max(0, Math.min(newIdx, props.images.length - 1))
},
{ immediate: true }
)
watch(
() => props.visible,
visible => {
if (visible) {
zoomScale.value = 1
rotationDeg.value = 0
}
}
)
const currentItem = computed<PreviewScreenshotItem | null>(() => {
if (!props.images.length) return null
return props.images[currentIndex.value] || props.images[0] || null
})
const currentDisplayUrl = computed(() => {
if (!currentItem.value) return ''
return props.previewUrls[currentItem.value.url] || currentItem.value.url
})
const imageTransformStyle = computed(() => ({
transform: `scale(${zoomScale.value}) rotate(${rotationDeg.value}deg)`,
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}))
function handlePrev() {
if (currentIndex.value > 0) {
currentIndex.value -= 1
resetTransform()
} else if (props.images.length > 1) {
currentIndex.value = props.images.length - 1
resetTransform()
}
}
function handleNext() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value += 1
resetTransform()
} else if (props.images.length > 1) {
currentIndex.value = 0
resetTransform()
}
}
function handleZoomIn() {
zoomScale.value = Math.min(3, +(zoomScale.value + 0.25).toFixed(2))
}
function handleZoomOut() {
zoomScale.value = Math.max(0.5, +(zoomScale.value - 0.25).toFixed(2))
}
function handleResetZoom() {
zoomScale.value = 1
rotationDeg.value = 0
}
function handleRotate() {
rotationDeg.value = (rotationDeg.value + 90) % 360
}
function resetTransform() {
zoomScale.value = 1
rotationDeg.value = 0
}
function lockToDialog() {
emit('update:isPeekMode', false)
}
function handleClose() {
emit('update:visible', false)
emit('update:isPeekMode', false)
}
function handleEdit() {
if (currentItem.value) {
emit('edit', { shot: currentItem.value, index: currentIndex.value })
handleClose()
}
}
function handleOpenRaw() {
if (currentItem.value) {
emit('openRaw', currentItem.value.url)
}
}
function handleGlobalMouseUp() {
if (props.visible && props.isPeekMode) {
handleClose()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (!props.visible) return
if (e.key === 'Escape') {
e.stopPropagation()
handleClose()
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
handlePrev()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
handleNext()
}
}
onMounted(() => {
window.addEventListener('mouseup', handleGlobalMouseUp)
window.addEventListener('touchend', handleGlobalMouseUp)
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('mouseup', handleGlobalMouseUp)
window.removeEventListener('touchend', handleGlobalMouseUp)
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<template>
<Teleport to="body">
<Transition name="preview-fade">
<div
v-if="visible && currentItem"
class="admin-preview-overlay"
:class="{ 'is-peek': isPeekMode }"
@click.self="handleClose"
>
<!-- Peek Mode Floating Top Status Bar -->
<div v-if="isPeekMode" class="peek-hud-pill">
<span class="hud-dot" />
<span class="hud-text">按住速览中 · 松开鼠标自动关闭</span>
<button class="hud-btn-lock" type="button" @click.stop="lockToDialog">
<el-icon><Lock /></el-icon>
<span>固定为弹窗</span>
</button>
</div>
<!-- Dialog Container -->
<div class="admin-preview-container" :class="{ 'is-peek': isPeekMode }">
<!-- Top Header -->
<div class="preview-header">
<div class="header-left">
<span class="category-pill">{{ currentItem.label }}</span>
<span v-if="images.length > 1" class="index-badge">
{{ currentIndex + 1 }} / {{ images.length }}
</span>
<span v-if="!isPeekMode" class="hotkey-tip">快捷键:← / → 翻页 · Esc 退出</span>
</div>
<!-- Toolbar Controls -->
<div class="header-toolbar">
<button
class="tool-btn"
title="缩小"
type="button"
:disabled="zoomScale <= 0.5"
@click="handleZoomOut"
>
<el-icon><ZoomOut /></el-icon>
</button>
<button
class="tool-btn reset-btn"
title="还原比例"
type="button"
@click="handleResetZoom"
>
{{ Math.round(zoomScale * 100) }}%
</button>
<button
class="tool-btn"
title="放大"
type="button"
:disabled="zoomScale >= 3"
@click="handleZoomIn"
>
<el-icon><ZoomIn /></el-icon>
</button>
<button class="tool-btn" title="顺时针旋转 90°" type="button" @click="handleRotate">
<el-icon><RefreshRight /></el-icon>
</button>
<button
class="tool-btn"
title="新标签页打开原图"
type="button"
@click="handleOpenRaw"
>
<el-icon><TopRight /></el-icon>
</button>
<button
class="tool-btn close-btn"
title="关闭预览 (Esc)"
type="button"
@click="handleClose"
>
<el-icon><Close /></el-icon>
</button>
</div>
</div>
<!-- Main Image Viewport -->
<div class="preview-body" @click.self="handleClose">
<!-- Left Arrow -->
<button
v-if="images.length > 1"
class="nav-btn prev-btn"
title="上一张 (←)"
type="button"
@click.stop="handlePrev"
>
<el-icon><ArrowLeft /></el-icon>
</button>
<!-- Image Stage -->
<div class="image-stage" @click.self="handleClose">
<img
:src="currentDisplayUrl"
:alt="currentItem.label"
:style="imageTransformStyle"
class="preview-image"
draggable="false"
/>
</div>
<!-- Right Arrow -->
<button
v-if="images.length > 1"
class="nav-btn next-btn"
title="下一张 (→)"
type="button"
@click.stop="handleNext"
>
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<!-- Footer Action Bar -->
<div class="preview-footer">
<div class="footer-left">
<span class="file-info">{{ currentItem.label }}</span>
</div>
<div class="footer-actions">
<button class="action-btn edit-btn" type="button" @click="handleEdit">
<el-icon><EditPen /></el-icon>
<span>在线编辑 / 马赛克打码</span>
</button>
<button class="action-btn secondary-btn" type="button" @click="handleOpenRaw">
<el-icon><FullScreen /></el-icon>
<span>浏览器原图</span>
</button>
<button class="action-btn close-action-btn" type="button" @click="handleClose">
<span>关闭预览 (Esc)</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.preview-fade-enter-active,
.preview-fade-leave-active {
transition:
opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.preview-fade-enter-from,
.preview-fade-leave-to {
opacity: 0;
transform: scale(0.97);
}
.admin-preview-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 3500;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(10, 15, 29, 0.88);
backdrop-filter: blur(8px);
padding: 24px;
box-sizing: border-box;
user-select: none;
}
.peek-hud-pill {
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 16px;
border-radius: 999px;
background: rgba(30, 41, 59, 0.92);
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
color: #f8fafc;
font-size: 13px;
font-weight: 600;
z-index: 3600;
animation: hudPulse 2s infinite ease-in-out;
}
@keyframes hudPulse {
0%,
100% {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
50% {
box-shadow:
0 8px 28px rgba(59, 130, 246, 0.35),
0 0 0 1px rgba(96, 165, 250, 0.4);
}
}
.hud-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #38bdf8;
box-shadow: 0 0 8px #38bdf8;
}
.hud-btn-lock {
display: inline-flex;
align-items: center;
gap: 5px;
background: #2563eb;
color: #ffffff;
border: none;
border-radius: 6px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.hud-btn-lock:hover {
background: #1d4ed8;
}
.admin-preview-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1060px;
max-height: 88vh;
background: #0f172a;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.06);
overflow: hidden;
animation: modalPop 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalPop {
from {
opacity: 0;
transform: scale(0.96) translateY(6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: rgba(15, 23, 42, 0.95);
border-bottom: 1px solid rgba(51, 65, 85, 0.6);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.category-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
font-size: 13px;
font-weight: 700;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
}
.index-badge {
color: #94a3b8;
font-size: 13px;
font-weight: 600;
font-family: monospace;
}
.hotkey-tip {
color: #64748b;
font-size: 12px;
margin-left: 8px;
}
.header-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(51, 65, 85, 0.5);
border: 1px solid rgba(148, 163, 184, 0.15);
color: #e2e8f0;
font-size: 15px;
cursor: pointer;
transition: all 0.2s ease;
}
.tool-btn:hover:not(:disabled) {
background: rgba(71, 85, 105, 0.8);
color: #ffffff;
border-color: rgba(148, 163, 184, 0.3);
}
.tool-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tool-btn.reset-btn {
width: auto;
padding: 0 8px;
font-size: 12px;
font-weight: 600;
}
.tool-btn.close-btn:hover {
background: #ef4444;
border-color: #ef4444;
color: #ffffff;
}
.preview-body {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 420px;
max-height: calc(88vh - 120px);
background: #020617;
overflow: hidden;
padding: 16px;
}
.image-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: calc(88vh - 160px);
object-fit: contain;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
transform-origin: center center;
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(30, 41, 59, 0.75);
border: 1px solid rgba(148, 163, 184, 0.2);
color: #f8fafc;
font-size: 20px;
cursor: pointer;
z-index: 10;
backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-btn:hover {
background: rgba(37, 99, 235, 0.9);
color: #ffffff;
transform: translateY(-50%) scale(1.1);
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.4);
}
.prev-btn {
left: 20px;
}
.next-btn {
right: 20px;
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: rgba(15, 23, 42, 0.95);
border-top: 1px solid rgba(51, 65, 85, 0.6);
}
.file-info {
color: #94a3b8;
font-size: 13px;
font-weight: 500;
}
.footer-actions {
display: flex;
align-items: center;
gap: 10px;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.edit-btn {
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
color: #ffffff;
box-shadow: 0 2px 8px rgba(217, 119, 6, 0.35);
}
.edit-btn:hover {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
transform: translateY(-1px);
}
.secondary-btn {
background: rgba(51, 65, 85, 0.6);
border-color: rgba(148, 163, 184, 0.2);
color: #e2e8f0;
}
.secondary-btn:hover {
background: rgba(71, 85, 105, 0.8);
color: #ffffff;
}
.close-action-btn {
background: transparent;
color: #94a3b8;
border-color: rgba(148, 163, 184, 0.15);
}
.close-action-btn:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
</style>
@@ -0,0 +1,347 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { Listing } from '@/features/listings'
import { centToYuan, formatMoneyWithSymbol, yuanToCent } from '@/shared/utils/money'
import { getCoinWan, getListingConsumablePrice } from '@/shared/utils/listingDisplay'
const props = defineProps<{
visible: boolean
listing: Listing | null
loading: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(
e: 'save',
payload: {
listingId: number
payload: { buyer_ratio?: number; buyer_total_price_cent?: number; reason: string }
}
): void
}>()
const priceAdjustMode = ref<'ratio' | 'price'>('ratio')
const priceAdjustForm = reactive({
buyer_ratio: 0,
buyer_total_price: 0,
reason: '',
})
watch(
() => [props.visible, props.listing] as const,
([visible, listing]) => {
if (visible && listing) {
priceAdjustMode.value = 'ratio'
priceAdjustForm.buyer_ratio = buyerRatio(listing)
priceAdjustForm.buyer_total_price = buyerTotalPrice(listing)
priceAdjustForm.reason = ''
}
}
)
watch(priceAdjustMode, (mode, previousMode) => {
if (!props.listing || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(props.listing, previousMode)
if (mode === 'price') {
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(props.listing)
} else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(props.listing)
}
})
const priceAdjustPreview = computed(() =>
props.listing ? calculatePriceAdjustPreview(props.listing) : null
)
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function quantity(value: number) {
const rounded = Math.round(Number(value || 0) * 10) / 10
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1)
}
function ratioText(value: number) {
return value > 0 ? `1:${quantity(value)}` : '-'
}
function previewMoney(value: number) {
return value > 0 ? money(value) : '--'
}
function roundPreviewMoney(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round(value * 10) / 10
}
function roundPreviewRatio(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.round(value * 10) / 10
}
function priceBreakdown(row: Listing) {
const breakdown = row.asset_summary?.price_breakdown
return typeof breakdown === 'object' && breakdown !== null
? (breakdown as Record<string, unknown>)
: {}
}
function breakdownNumber(row: Listing, key: string) {
const value = priceBreakdown(row)[key]
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function sellerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'seller_total_price')
if (value > 0) return value
const price = centToYuan(row.price_cent)
const fallback = price - getListingConsumablePrice(row)
return fallback > 0 ? fallback : price
}
function sellerCoinBasePrice(row: Listing) {
const value = breakdownNumber(row, 'seller_coin_base_price')
if (value > 0) return value
return Math.max(0, sellerTotalPrice(row) - getListingConsumablePrice(row))
}
function sellerRatio(row: Listing) {
const value =
breakdownNumber(row, 'seller_ratio') || breakdownNumber(row, 'seller_reference_ratio')
if (value > 0) return value
const base = sellerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
}
function buyerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'buyer_total_price')
return value > 0 ? value : centToYuan(row.price_cent)
}
function buyerCoinBasePrice(row: Listing) {
const value = breakdownNumber(row, 'buyer_coin_base_price')
if (value > 0) return value
return Math.max(0, buyerTotalPrice(row) - getListingConsumablePrice(row))
}
function buyerRatio(row: Listing) {
const value = breakdownNumber(row, 'buyer_ratio')
if (value > 0) return value
const base = buyerCoinBasePrice(row)
return base > 0 ? getCoinWan(row) / base : 0
}
function calculatePriceAdjustPreview(
row: Listing,
mode: 'ratio' | 'price' = priceAdjustMode.value
) {
const consumablePrice = getListingConsumablePrice(row)
const coinWan = getCoinWan(row)
if (mode === 'price') {
const buyerTotalPrice = roundPreviewMoney(Number(priceAdjustForm.buyer_total_price || 0))
const buyerCoinBasePrice = roundPreviewMoney(buyerTotalPrice - consumablePrice)
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
const buyerRatioInput = Number(priceAdjustForm.buyer_ratio || 0)
const buyerCoinBasePrice = buyerRatioInput > 0 ? roundPreviewMoney(coinWan / buyerRatioInput) : 0
const buyerTotalPrice =
buyerCoinBasePrice > 0 ? roundPreviewMoney(buyerCoinBasePrice + consumablePrice) : 0
const buyerRatio = buyerCoinBasePrice > 0 ? roundPreviewRatio(coinWan / buyerCoinBasePrice) : 0
return { buyerCoinBasePrice, buyerTotalPrice, buyerRatio }
}
function handleSave() {
if (!props.listing) return
const payload =
priceAdjustMode.value === 'ratio'
? {
buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0),
reason: priceAdjustForm.reason.trim(),
}
: {
buyer_total_price_cent: yuanToCent(Number(priceAdjustForm.buyer_total_price || 0)),
reason: priceAdjustForm.reason.trim(),
}
if ((payload.buyer_ratio || payload.buyer_total_price_cent || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格')
return
}
emit('save', {
listingId: props.listing.id,
payload,
})
}
</script>
<template>
<el-dialog
:model-value="visible"
title="调整审核定价"
width="580px"
append-to-body
@update:model-value="emit('update:visible', $event)"
>
<div v-if="listing" class="dialog-body price-adjust-dialog">
<div class="adjust-preview-grid">
<div class="preview-box">
<span class="box-label">卖家发布价</span>
<strong class="box-val">{{ money(sellerTotalPrice(listing)) }}</strong>
<small>底价比例: {{ ratioText(sellerRatio(listing)) }}</small>
</div>
<div class="preview-box preview-box--highlight">
<span class="box-label">调整后买家展示价</span>
<strong class="box-val highlight-val">
{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}
</strong>
<small>含消耗品: {{ money(getListingConsumablePrice(listing)) }}</small>
</div>
<div class="preview-box">
<span class="box-label">调整后比例</span>
<strong class="box-val highlight-val">
{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}
</strong>
<small>纯币: {{ previewMoney(priceAdjustPreview?.buyerCoinBasePrice || 0) }}</small>
</div>
</div>
<div class="adjust-mode-section">
<el-radio-group v-model="priceAdjustMode" class="adjust-mode">
<el-radio-button label="ratio">按加价后比例调整</el-radio-button>
<el-radio-button label="price">按买家端总售价调整</el-radio-button>
</el-radio-group>
</div>
<div class="adjust-fields-box">
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span class="field-title">调整后买家比例 (哈夫币万 / 元)</span>
<el-input-number
v-model="priceAdjustForm.buyer_ratio"
:min="0.1"
:step="0.5"
:precision="1"
controls-position="right"
/>
</label>
<label v-else class="adjust-field">
<span class="field-title">调整后买家端展示总售价 (元)</span>
<el-input-number
v-model="priceAdjustForm.buyer_total_price"
:min="1"
:step="10"
:precision="0"
controls-position="right"
/>
</label>
<label class="adjust-field">
<span class="field-title">调价原因 (内部记录与审计追踪)</span>
<el-input
v-model="priceAdjustForm.reason"
type="textarea"
:rows="3"
maxlength="100"
show-word-limit
placeholder="例如:市场比例波动调整、大额号主协商加点、外部渠道定价校准等"
/>
</label>
</div>
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSave"> 保存并更新价格 </el-button>
</template>
</el-dialog>
</template>
<style scoped>
.adjust-preview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.preview-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.preview-box--highlight {
border-color: #93c5fd;
background: #eff6ff;
}
.box-label {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.box-val {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
.highlight-val {
color: #2563eb;
}
.preview-box small {
color: #64748b;
font-size: 11px;
}
.adjust-mode-section {
margin-bottom: 14px;
}
.adjust-mode {
width: 100%;
}
.adjust-mode :deep(.el-radio-button) {
width: 50%;
}
.adjust-mode :deep(.el-radio-button__inner) {
width: 100%;
font-weight: 700;
}
.adjust-fields-box {
display: flex;
flex-direction: column;
gap: 14px;
}
.adjust-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-title {
color: #475569;
font-size: 12px;
font-weight: 700;
}
.adjust-field :deep(.el-input-number) {
width: 100%;
}
</style>
@@ -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>
@@ -0,0 +1,93 @@
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
</script>
<template>
<el-dialog
:model-value="visible"
title="审核工作台快捷键指南"
width="480px"
append-to-body
@update:model-value="emit('update:visible', $event)"
>
<div class="shortcuts-guide-table">
<div class="shortcut-row">
<kbd class="key-badge">↑</kbd> 或 <kbd class="key-badge">K</kbd>
<span class="key-desc">切换选中上一个商品</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">↓</kbd> 或 <kbd class="key-badge">J</kbd>
<span class="key-desc">切换选中下一个商品</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">Alt</kbd> + <kbd class="key-badge">A</kbd> 或
<kbd class="key-badge">A</kbd>
<span class="key-desc">审核通过并立即上架</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">Alt</kbd> + <kbd class="key-badge">R</kbd> 或
<kbd class="key-badge">R</kbd>
<span class="key-desc">打开拒绝审核弹窗</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">Alt</kbd> + <kbd class="key-badge">P</kbd> 或
<kbd class="key-badge">P</kbd>
<span class="key-desc">打开价格调整弹窗</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">Esc</kbd>
<span class="key-desc">关闭当前弹窗或画廊预览</span>
</div>
<div class="shortcut-row">
<kbd class="key-badge">?</kbd>
<span class="key-desc">随时查看本快捷键指南</span>
</div>
</div>
<template #footer>
<el-button type="primary" @click="emit('update:visible', false)">知道了</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.shortcuts-guide-table {
display: flex;
flex-direction: column;
gap: 10px;
}
.shortcut-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
}
.key-badge {
display: inline-block;
padding: 2px 8px;
border: 1px solid #cbd5e1;
border-bottom-width: 2px;
border-radius: 4px;
background: #ffffff;
color: #0f172a;
font-family: monospace;
font-size: 12px;
font-weight: 700;
}
.key-desc {
margin-left: auto;
color: #475569;
font-weight: 600;
}
</style>
@@ -0,0 +1,888 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Delete, FullScreen, RefreshLeft, ZoomIn, ZoomOut } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchAdminFileBlob, fetchFileBlobByURL, uploadAdminFile } from '@/shared/api/files'
import { adminUpdateListingScreenshots, type Listing } from '@/features/listings'
import { readError } from '@/shared/utils/error'
const props = defineProps<{
visible: boolean
listing: Listing | null
imageUrl: string
imageLabel: string
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'saved', payload: { oldUrl: string; newUrl: string }): void
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const containerRef = ref<HTMLDivElement | null>(null)
// 编辑状态
type ToolMode = 'view' | 'mosaic' | 'crop'
const currentTool = ref<ToolMode>('mosaic')
const mosaicBrushSize = ref<number>(24) // 16, 24, 36, 48
const mosaicBlockSize = 8 // 马赛克块大小
// 缩放与视图
const zoomLevel = ref<number>(1)
const loading = ref<boolean>(false)
const saving = ref<boolean>(false)
// 撤销历史栈
const undoStack = ref<ImageData[]>([])
let originalImageData: ImageData | null = null
let activeObjectURL: string | null = null
// 裁剪框状态 (以画布原始像素为基准)
const cropRect = ref<{ x: number; y: number; width: number; height: number } | null>(null)
const isCropping = ref<boolean>(false)
let cropDragMode: string | null = null
let cropStartX = 0
let cropStartY = 0
let cropStartRect = { x: 0, y: 0, width: 0, height: 0 }
// 绘画状态
let isDrawing = false
let lastDrawPos: { x: number; y: number } | null = null
watch(
() => props.visible,
visible => {
if (visible && props.imageUrl) {
nextTick(() => {
initEditor()
})
} else {
cleanup()
}
}
)
function cleanup() {
if (activeObjectURL) {
URL.revokeObjectURL(activeObjectURL)
activeObjectURL = null
}
undoStack.value = []
originalImageData = null
cropRect.value = null
currentTool.value = 'mosaic'
}
onBeforeUnmount(cleanup)
function extractObjectKey(url: string) {
try {
const parsed = new URL(url, window.location.origin)
return parsed.searchParams.get('key') || ''
} catch {
return ''
}
}
async function initEditor() {
loading.value = true
try {
let blob: Blob
const key = extractObjectKey(props.imageUrl)
if (key) {
blob = await fetchAdminFileBlob(key)
} else {
blob = await fetchFileBlobByURL(props.imageUrl)
}
if (activeObjectURL) {
URL.revokeObjectURL(activeObjectURL)
}
activeObjectURL = URL.createObjectURL(blob)
const img = new Image()
img.crossOrigin = 'anonymous'
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = activeObjectURL!
})
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
originalImageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
undoStack.value = [ctx.getImageData(0, 0, canvas.width, canvas.height)]
fitToContainer()
currentTool.value = 'mosaic'
} catch (error) {
ElMessage.error(readError(error, '加载图片失败,请重试'))
} finally {
loading.value = false
}
}
function fitToContainer() {
const container = containerRef.value
const canvas = canvasRef.value
if (!container || !canvas) return
const containerW = container.clientWidth - 48
const containerH = container.clientHeight - 48
if (containerW <= 0 || containerH <= 0) return
const scaleW = containerW / canvas.width
const scaleH = containerH / canvas.height
zoomLevel.value = Math.min(1, Math.min(scaleW, scaleH))
}
// 坐标映射:视口鼠标事件 -> 画布内部像素坐标
function getCanvasPos(e: MouseEvent): { x: number; y: number } {
const canvas = canvasRef.value
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY),
}
}
// ---------------- 马赛克核心算法 ----------------
function applyMosaic(cx: number, cy: number, radius: number) {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return
const startX = Math.max(0, Math.floor(cx - radius))
const startY = Math.max(0, Math.floor(cy - radius))
const endX = Math.min(canvas.width, Math.ceil(cx + radius))
const endY = Math.min(canvas.height, Math.ceil(cy + radius))
const w = endX - startX
const h = endY - startY
if (w <= 0 || h <= 0) return
const imgData = ctx.getImageData(startX, startY, w, h)
const data = imgData.data
const bs = mosaicBlockSize
// 遍历块
for (let by = 0; by < h; by += bs) {
for (let bx = 0; bx < w; bx += bs) {
// 检查当前块中心是否在笔刷圆形范围内
const blockCenterX = startX + bx + bs / 2
const blockCenterY = startY + by + bs / 2
const dist = Math.hypot(blockCenterX - cx, blockCenterY - cy)
if (dist > radius) continue
let rSum = 0,
gSum = 0,
bSum = 0,
aSum = 0,
count = 0
const bw = Math.min(bs, w - bx)
const bh = Math.min(bs, h - by)
// 求平均值
for (let py = 0; py < bh; py++) {
for (let px = 0; px < bw; px++) {
const idx = ((by + py) * w + (bx + px)) * 4
rSum += data[idx] ?? 0
gSum += data[idx + 1] ?? 0
bSum += data[idx + 2] ?? 0
aSum += data[idx + 3] ?? 0
count++
}
}
if (count === 0) continue
const rAvg = Math.round(rSum / count)
const gAvg = Math.round(gSum / count)
const bAvg = Math.round(bSum / count)
const aAvg = Math.round(aSum / count)
// 填充块
for (let py = 0; py < bh; py++) {
for (let px = 0; px < bw; px++) {
const idx = ((by + py) * w + (bx + px)) * 4
data[idx] = rAvg
data[idx + 1] = gAvg
data[idx + 2] = bAvg
data[idx + 3] = aAvg
}
}
}
}
ctx.putImageData(imgData, startX, startY)
}
function handleMouseDown(e: MouseEvent) {
if (currentTool.value === 'mosaic') {
isDrawing = true
const pos = getCanvasPos(e)
lastDrawPos = pos
applyMosaic(pos.x, pos.y, mosaicBrushSize.value)
} else if (currentTool.value === 'crop') {
handleCropMouseDown(e)
}
}
function handleMouseMove(e: MouseEvent) {
if (currentTool.value === 'mosaic' && isDrawing) {
const pos = getCanvasPos(e)
if (lastDrawPos) {
// 线性插值,平滑打码连线
const dist = Math.hypot(pos.x - lastDrawPos.x, pos.y - lastDrawPos.y)
const step = Math.max(1, mosaicBrushSize.value / 3)
const count = Math.ceil(dist / step)
for (let i = 1; i <= count; i++) {
const t = i / count
const ix = lastDrawPos.x + (pos.x - lastDrawPos.x) * t
const iy = lastDrawPos.y + (pos.y - lastDrawPos.y) * t
applyMosaic(ix, iy, mosaicBrushSize.value)
}
} else {
applyMosaic(pos.x, pos.y, mosaicBrushSize.value)
}
lastDrawPos = pos
} else if (currentTool.value === 'crop') {
handleCropMouseMove(e)
}
}
function handleMouseUp() {
if (currentTool.value === 'mosaic' && isDrawing) {
isDrawing = false
lastDrawPos = null
pushUndoState()
} else if (currentTool.value === 'crop') {
handleCropMouseUp()
}
}
function pushUndoState() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const current = ctx.getImageData(0, 0, canvas.width, canvas.height)
undoStack.value.push(current)
if (undoStack.value.length > 20) {
undoStack.value.shift()
}
}
function handleUndo() {
if (undoStack.value.length <= 1) return
undoStack.value.pop() // 弹出当前状态
const prev = undoStack.value[undoStack.value.length - 1]
const canvas = canvasRef.value
if (!canvas || !prev) return
canvas.width = prev.width
canvas.height = prev.height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.putImageData(prev, 0, 0)
}
cropRect.value = null
}
function handleReset() {
if (!originalImageData) return
const canvas = canvasRef.value
if (!canvas) return
canvas.width = originalImageData.width
canvas.height = originalImageData.height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.putImageData(originalImageData, 0, 0)
}
undoStack.value = [originalImageData]
cropRect.value = null
currentTool.value = 'mosaic'
fitToContainer()
ElMessage.info('已恢复为原图')
}
// ---------------- 裁剪相关交互 ----------------
function startCropMode() {
currentTool.value = 'crop'
const canvas = canvasRef.value
if (!canvas) return
// 默认选中中间 80%
const w = Math.round(canvas.width * 0.8)
const h = Math.round(canvas.height * 0.8)
const x = Math.round((canvas.width - w) / 2)
const y = Math.round((canvas.height - h) / 2)
cropRect.value = { x, y, width: w, height: h }
}
function handleCropMouseDown(e: MouseEvent) {
const target = e.target as HTMLElement
const handle = target.dataset.handle
const pos = getCanvasPos(e)
if (handle) {
cropDragMode = handle
} else if (cropRect.value && isInsideCrop(pos.x, pos.y)) {
cropDragMode = 'move'
} else {
// 重新拉一个裁剪框
cropDragMode = 'create'
cropRect.value = { x: pos.x, y: pos.y, width: 0, height: 0 }
}
cropStartX = pos.x
cropStartY = pos.y
cropStartRect = cropRect.value
? { ...cropRect.value }
: { x: pos.x, y: pos.y, width: 0, height: 0 }
isCropping.value = true
}
function isInsideCrop(cx: number, cy: number) {
if (!cropRect.value) return false
const r = cropRect.value
return cx >= r.x && cx <= r.x + r.width && cy >= r.y && cy <= r.y + r.height
}
function handleCropMouseMove(e: MouseEvent) {
if (!isCropping.value || !cropDragMode || !cropRect.value) return
const canvas = canvasRef.value
if (!canvas) return
const pos = getCanvasPos(e)
const dx = pos.x - cropStartX
const dy = pos.y - cropStartY
if (cropDragMode === 'create') {
const x = Math.min(cropStartX, pos.x)
const y = Math.min(cropStartY, pos.y)
const width = Math.abs(pos.x - cropStartX)
const height = Math.abs(pos.y - cropStartY)
cropRect.value = {
x: Math.max(0, x),
y: Math.max(0, y),
width: Math.min(canvas.width - x, width),
height: Math.min(canvas.height - y, height),
}
} else if (cropDragMode === 'move') {
let nx = cropStartRect.x + dx
let ny = cropStartRect.y + dy
nx = Math.max(0, Math.min(canvas.width - cropStartRect.width, nx))
ny = Math.max(0, Math.min(canvas.height - cropStartRect.height, ny))
cropRect.value = {
...cropStartRect,
x: nx,
y: ny,
}
} else if (cropDragMode === 'nw') {
const newX = Math.max(
0,
Math.min(cropStartRect.x + cropStartRect.width - 20, cropStartRect.x + dx)
)
const newY = Math.max(
0,
Math.min(cropStartRect.y + cropStartRect.height - 20, cropStartRect.y + dy)
)
cropRect.value = {
x: newX,
y: newY,
width: cropStartRect.x + cropStartRect.width - newX,
height: cropStartRect.y + cropStartRect.height - newY,
}
} else if (cropDragMode === 'se') {
const newW = Math.max(20, Math.min(canvas.width - cropStartRect.x, cropStartRect.width + dx))
const newH = Math.max(20, Math.min(canvas.height - cropStartRect.y, cropStartRect.height + dy))
cropRect.value = {
...cropStartRect,
width: newW,
height: newH,
}
} else if (cropDragMode === 'ne') {
const newY = Math.max(
0,
Math.min(cropStartRect.y + cropStartRect.height - 20, cropStartRect.y + dy)
)
const newW = Math.max(20, Math.min(canvas.width - cropStartRect.x, cropStartRect.width + dx))
cropRect.value = {
x: cropStartRect.x,
y: newY,
width: newW,
height: cropStartRect.y + cropStartRect.height - newY,
}
} else if (cropDragMode === 'sw') {
const newX = Math.max(
0,
Math.min(cropStartRect.x + cropStartRect.width - 20, cropStartRect.x + dx)
)
const newH = Math.max(20, Math.min(canvas.height - cropStartRect.y, cropStartRect.height + dy))
cropRect.value = {
x: newX,
y: cropStartRect.y,
width: cropStartRect.x + cropStartRect.width - newX,
height: newH,
}
}
}
function handleCropMouseUp() {
isCropping.value = false
cropDragMode = null
}
function applyCrop() {
if (!cropRect.value) return
const r = cropRect.value
if (r.width < 10 || r.height < 10) {
ElMessage.warning('裁剪区域过小')
return
}
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const croppedData = ctx.getImageData(r.x, r.y, r.width, r.height)
canvas.width = r.width
canvas.height = r.height
ctx.putImageData(croppedData, 0, 0)
cropRect.value = null
currentTool.value = 'mosaic'
pushUndoState()
fitToContainer()
ElMessage.success('裁剪已应用')
}
function cancelCrop() {
cropRect.value = null
currentTool.value = 'mosaic'
}
// ---------------- 保存与替换 ----------------
async function handleSave() {
const canvas = canvasRef.value
if (!canvas || !props.listing) return
saving.value = true
try {
const blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/jpeg', 0.92)
})
if (!blob) {
throw new Error('导出画布失败')
}
const file = new File([blob], `edited_screenshot_${Date.now()}.jpg`, { type: 'image/jpeg' })
const uploaded = await uploadAdminFile(file, 'listing_screenshot')
if (!uploaded || !uploaded.url) {
throw new Error('上传截图失败')
}
// 替换 listing 中的 screenshot_urls
const currentList = [...(props.listing.screenshot_urls || [])]
const targetIdx = currentList.findIndex(u => u === props.imageUrl)
if (targetIdx !== -1) {
currentList[targetIdx] = uploaded.url
} else {
currentList.push(uploaded.url)
}
// 同步替换 screenshot_groups 中的相应项
let updatedGroups: Record<string, unknown> | undefined = undefined
const groups = props.listing.asset_summary?.screenshot_groups
if (typeof groups === 'object' && groups !== null) {
const copyGroups: Record<string, unknown> = JSON.parse(JSON.stringify(groups))
for (const [key, val] of Object.entries(copyGroups)) {
if (Array.isArray(val)) {
copyGroups[key] = val.map(u => (u === props.imageUrl ? uploaded.url : u))
}
}
updatedGroups = copyGroups
}
await adminUpdateListingScreenshots(props.listing.id, {
screenshot_urls: currentList,
screenshot_groups: updatedGroups,
})
emit('saved', { oldUrl: props.imageUrl, newUrl: uploaded.url })
ElMessage.success('截图已成功更新并保存')
emit('update:visible', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败,请稍后重试'))
} finally {
saving.value = false
}
}
// 辅助样式:裁剪遮罩计算
const cropStyle = computed(() => {
if (!cropRect.value || !canvasRef.value) return {}
const canvas = canvasRef.value
const r = cropRect.value
return {
left: `${(r.x / canvas.width) * 100}%`,
top: `${(r.y / canvas.height) * 100}%`,
width: `${(r.width / canvas.width) * 100}%`,
height: `${(r.height / canvas.height) * 100}%`,
}
})
</script>
<template>
<el-dialog
:model-value="visible"
:title="`编辑截图 · ${imageLabel || '账号截图'}`"
width="940px"
top="4vh"
destroy-on-close
class="image-editor-dialog"
@update:model-value="emit('update:visible', $event)"
>
<div v-loading="loading" class="editor-shell">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<div class="tool-group">
<span class="tool-group-label">工具:</span>
<el-radio-group v-model="currentTool" size="small">
<el-radio-button value="mosaic">马赛克打码</el-radio-button>
<el-radio-button value="crop" @click="startCropMode">矩形裁剪</el-radio-button>
</el-radio-group>
</div>
<!-- 马赛克大小选项 -->
<div v-if="currentTool === 'mosaic'" class="tool-group mosaic-options">
<span class="tool-group-label">画笔大小:</span>
<div class="brush-size-chips">
<button
type="button"
class="size-chip"
:class="{ active: mosaicBrushSize === 16 }"
@click="mosaicBrushSize = 16"
>
小 (16px)
</button>
<button
type="button"
class="size-chip"
:class="{ active: mosaicBrushSize === 28 }"
@click="mosaicBrushSize = 28"
>
中 (28px)
</button>
<button
type="button"
class="size-chip"
:class="{ active: mosaicBrushSize === 44 }"
@click="mosaicBrushSize = 44"
>
大 (44px)
</button>
</div>
</div>
<!-- 裁剪操作按钮 -->
<div v-if="currentTool === 'crop'" class="tool-group crop-actions">
<el-button type="primary" size="small" @click="applyCrop">应用裁剪</el-button>
<el-button size="small" @click="cancelCrop">取消裁剪</el-button>
</div>
<div class="toolbar-divider"></div>
<!-- 历史与重置 -->
<div class="tool-group">
<el-tooltip content="撤销上一步" placement="top">
<el-button
size="small"
:icon="RefreshLeft"
:disabled="undoStack.length <= 1"
@click="handleUndo"
/>
</el-tooltip>
<el-tooltip content="重置为原图" placement="top">
<el-button size="small" :icon="Delete" @click="handleReset" />
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<!-- 缩放操作 -->
<div class="tool-group zoom-group">
<el-tooltip content="放大" placement="top">
<el-button
size="small"
:icon="ZoomIn"
:disabled="zoomLevel >= 2"
@click="zoomLevel = Math.min(2, zoomLevel + 0.15)"
/>
</el-tooltip>
<span class="zoom-text">{{ Math.round(zoomLevel * 100) }}%</span>
<el-tooltip content="缩小" placement="top">
<el-button
size="small"
:icon="ZoomOut"
:disabled="zoomLevel <= 0.2"
@click="zoomLevel = Math.max(0.2, zoomLevel - 0.15)"
/>
</el-tooltip>
<el-tooltip content="适应屏幕" placement="top">
<el-button size="small" :icon="FullScreen" @click="fitToContainer" />
</el-tooltip>
</div>
</div>
<!-- 画布工作区 -->
<div
ref="containerRef"
class="canvas-workspace"
:class="`tool-${currentTool}`"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<div
class="canvas-wrapper"
:style="{
transform: `scale(${zoomLevel})`,
}"
>
<canvas ref="canvasRef" class="editor-canvas" />
<!-- 裁剪框覆盖层 -->
<div
v-if="currentTool === 'crop' && cropRect"
class="crop-overlay-box"
:style="cropStyle"
>
<div class="crop-border"></div>
<!-- 八向控制手柄 -->
<div class="crop-handle nw" data-handle="nw"></div>
<div class="crop-handle ne" data-handle="ne"></div>
<div class="crop-handle sw" data-handle="sw"></div>
<div class="crop-handle se" data-handle="se"></div>
</div>
</div>
</div>
<!-- 底部提示与尺寸说明 -->
<div class="editor-statusbar">
<span v-if="currentTool === 'mosaic'" class="status-hint">
💡 提示:在图片需要遮挡的敏感信息(账号ID、二维码等)上按住鼠标左键涂抹即可打马赛克。
</span>
<span v-else-if="currentTool === 'crop'" class="status-hint">
💡 提示:拖拽裁剪边框调整所选范围,调整完毕后点击上方「应用裁剪」。
</span>
<span v-if="canvasRef" class="status-meta">
画布尺寸:{{ canvasRef.width }} × {{ canvasRef.height }} 像素
</span>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存并替换截图</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.editor-shell {
display: flex;
flex-direction: column;
height: 640px;
background: #0f172a;
border-radius: 8px;
overflow: hidden;
}
/* 顶部操作条 */
.editor-toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
background: #1e293b;
border-bottom: 1px solid #334155;
color: #f1f5f9;
flex-shrink: 0;
user-select: none;
}
.tool-group {
display: flex;
align-items: center;
gap: 8px;
}
.tool-group-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #334155;
margin: 0 4px;
}
.brush-size-chips {
display: flex;
gap: 6px;
}
.size-chip {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid #475569;
background: #334155;
color: #cbd5e1;
cursor: pointer;
transition: all 0.15s ease;
}
.size-chip:hover {
border-color: #ff8533;
color: #fff;
}
.size-chip.active {
background: #ff6a00;
border-color: #ff6a00;
color: #fff;
}
.zoom-text {
font-family: monospace;
font-size: 12px;
font-weight: 700;
color: #cbd5e1;
min-width: 42px;
text-align: center;
}
/* 画布工作区 */
.canvas-workspace {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: #0b1120;
background-image:
radial-gradient(#1e293b 1px, transparent 1px), radial-gradient(#1e293b 1px, #0b1120 1px);
background-size: 20px 20px;
background-position:
0 0,
10px 10px;
}
.canvas-workspace.tool-mosaic {
cursor: crosshair;
}
.canvas-workspace.tool-crop {
cursor: default;
}
.canvas-wrapper {
position: relative;
transform-origin: center center;
transition: transform 0.05s ease-out;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
user-select: none;
}
.editor-canvas {
display: block;
background: #fff;
border-radius: 4px;
}
/* 裁剪框 */
.crop-overlay-box {
position: absolute;
pointer-events: auto;
border: 2px dashed #ff6a00;
background: rgba(255, 106, 0, 0.08);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
cursor: move;
}
.crop-handle {
position: absolute;
width: 10px;
height: 10px;
background: #ffffff;
border: 2px solid #ff6a00;
border-radius: 2px;
}
.crop-handle.nw {
top: -6px;
left: -6px;
cursor: nwse-resize;
}
.crop-handle.ne {
top: -6px;
right: -6px;
cursor: nesw-resize;
}
.crop-handle.sw {
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
.crop-handle.se {
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
/* 底部状态栏 */
.editor-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #1e293b;
border-top: 1px solid #334155;
font-size: 12px;
color: #94a3b8;
flex-shrink: 0;
}
.status-hint {
color: #e2e8f0;
}
.status-meta {
font-family: monospace;
color: #64748b;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -239,7 +239,7 @@
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px 16px;
}
@@ -32,7 +32,7 @@ import { useAdminSessionStore } from '@/stores/adminSession'
import { adminPath } from '@/shared/utils/adminPath'
import { readError } from '@/shared/utils/error'
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
import { formatDateTime } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
@@ -73,14 +73,89 @@ const createForm = reactive({
voucher_url: '',
})
const defaultStartTime = new Date(2000, 0, 1, 0, 0, 0)
const defaultEndTime = new Date(2000, 0, 1, 23, 59, 59)
const startTimeShortcuts = [
{
text: '今天 00:00',
value: () => {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d
},
},
{
text: '昨天 00:00',
value: () => {
const d = new Date()
d.setDate(d.getDate() - 1)
d.setHours(0, 0, 0, 0)
return d
},
},
{
text: '近7天',
value: () => {
const d = new Date()
d.setDate(d.getDate() - 6)
d.setHours(0, 0, 0, 0)
return d
},
},
{
text: '近30天',
value: () => {
const d = new Date()
d.setDate(d.getDate() - 29)
d.setHours(0, 0, 0, 0)
return d
},
},
{
text: '本月初',
value: () => {
const d = new Date()
d.setDate(1)
d.setHours(0, 0, 0, 0)
return d
},
},
]
const endTimeShortcuts = [
{
text: '此刻',
value: () => new Date(),
},
{
text: '今天 23:59',
value: () => {
const d = new Date()
d.setHours(23, 59, 59, 0)
return d
},
},
{
text: '昨天 23:59',
value: () => {
const d = new Date()
d.setDate(d.getDate() - 1)
d.setHours(23, 59, 59, 0)
return d
},
},
]
const filters = reactive({
date_type: 'created',
start_date: defaultStartDate(),
end_date: formatInputDate(new Date()),
start_date: defaultStartDateTime(),
end_date: defaultEndDateTime(),
source_type: '',
status: '',
business_no: '',
keyword: '',
operator: '',
user_id: '',
})
@@ -114,12 +189,13 @@ function search() {
function resetFilters() {
filters.date_type = 'created'
filters.start_date = defaultStartDate()
filters.end_date = formatInputDate(new Date())
filters.start_date = defaultStartDateTime()
filters.end_date = defaultEndDateTime()
filters.source_type = ''
filters.status = ''
filters.business_no = ''
filters.keyword = ''
filters.operator = ''
filters.user_id = ''
currentPage.value = 1
void loadItems()
@@ -261,10 +337,17 @@ function emptySummary(): FinanceDisbursementListSummary {
}
}
function defaultStartDate() {
function defaultStartDateTime() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
date.setHours(0, 0, 0, 0)
return formatDateTime(date)
}
function defaultEndDateTime() {
const date = new Date()
date.setHours(23, 59, 59, 0)
return formatDateTime(date)
}
function money(cents: number | undefined | null) {
@@ -444,29 +527,37 @@ function accountTypeLabel(type: string) {
<!-- 筛选面板 -->
<section class="section-card filter-card">
<el-form class="filter-grid" label-position="top" @keyup.enter="search">
<el-form-item label="日期口径">
<el-form-item label="时间口径">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="业务形成日期" value="created" />
<el-option label="实际打款日期" value="paid" />
<el-option label="业务形成时间" value="created" />
<el-option label="实际打款时间" value="paid" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-form-item label="开始时间">
<el-date-picker
v-model="filters.start_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择开始时间"
:default-time="defaultStartTime"
:shortcuts="startTimeShortcuts"
:clearable="false"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-form-item label="结束时间">
<el-date-picker
v-model="filters.end_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择结束时间"
:default-time="defaultEndTime"
:shortcuts="endTimeShortcuts"
:clearable="false"
/>
</el-form-item>
@@ -509,6 +600,10 @@ function accountTypeLabel(type: string) {
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="filters.operator" clearable placeholder="姓名、用户名或管理员 ID" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户 ID 检索" />
</el-form-item>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff