merge(main): 同步主站最新 UI 与审核优化代码并保持分销多租户隔离
This commit is contained in:
@@ -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
Reference in New Issue
Block a user