优化后台商品审核价格调整

This commit is contained in:
yml2213
2026-06-04 23:44:16 +08:00
parent c1b3dba83d
commit fbf1b37399
10 changed files with 653 additions and 33 deletions
@@ -4,11 +4,10 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
import { adjustListingReviewPrice, approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/features/listings'
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getListingConsumablePrice,
getListingResources,
@@ -36,6 +35,14 @@ const selectedID = ref<number | null>(null)
const activeListing = ref<Listing | null>(null)
const evidenceListing = ref<Listing | null>(null)
const rejectReason = ref('')
const priceAdjustListing = ref<Listing | null>(null)
const adjustingPrice = ref(false)
const priceAdjustMode = ref<'ratio' | 'price'>('ratio')
const priceAdjustForm = reactive({
buyer_ratio: 0,
buyer_total_price: 0,
reason: '',
})
const filters = reactive({
keyword: '',
risk: 'all',
@@ -57,6 +64,7 @@ const filteredListings = computed(() => {
const activeRisks = computed(() => (selectedListing.value ? riskItems(selectedListing.value) : []))
const selectedResources = computed(() => (selectedListing.value ? getListingResources(selectedListing.value) : []))
const selectedSkins = computed(() => (selectedListing.value ? getSkinNames(selectedListing.value) : []))
const priceAdjustPreview = computed(() => (priceAdjustListing.value ? calculatePriceAdjustPreview(priceAdjustListing.value) : null))
watch(
() => selectedListing.value,
@@ -69,6 +77,16 @@ watch(
{ immediate: true },
)
watch(priceAdjustMode, (mode, previousMode) => {
if (!priceAdjustListing.value || !previousMode || mode === previousMode) return
const preview = calculatePriceAdjustPreview(priceAdjustListing.value, previousMode)
if (mode === 'price') {
priceAdjustForm.buyer_total_price = preview.buyerTotalPrice || buyerTotalPrice(priceAdjustListing.value)
} else {
priceAdjustForm.buyer_ratio = preview.buyerRatio || buyerRatio(priceAdjustListing.value)
}
})
onMounted(loadListings)
onBeforeUnmount(() => {
createdObjectURLs.forEach((url) => URL.revokeObjectURL(url))
@@ -90,6 +108,16 @@ function selectListing(row: Listing) {
selectedID.value = row.id
}
function replaceListing(next: Listing) {
const index = listings.value.findIndex((item) => item.id === next.id)
if (index >= 0) {
listings.value.splice(index, 1, next)
} else {
listings.value.unshift(next)
}
selectedID.value = next.id
}
async function handleApprove(row: Listing) {
try {
await ElMessageBox.confirm(`确认通过「${row.title}」并上架?`, '审核通过确认', {
@@ -117,6 +145,37 @@ function openReject(row: Listing) {
rejectReason.value = row.review_reason || ''
}
function openPriceAdjust(row: Listing) {
priceAdjustListing.value = row
priceAdjustMode.value = 'ratio'
priceAdjustForm.buyer_ratio = buyerRatio(row)
priceAdjustForm.buyer_total_price = buyerTotalPrice(row)
priceAdjustForm.reason = ''
}
async function handleSavePriceAdjust() {
if (!priceAdjustListing.value) return
const payload =
priceAdjustMode.value === 'ratio'
? { buyer_ratio: Number(priceAdjustForm.buyer_ratio || 0), reason: priceAdjustForm.reason.trim() }
: { buyer_total_price: Number(priceAdjustForm.buyer_total_price || 0), reason: priceAdjustForm.reason.trim() }
if ((payload.buyer_ratio || payload.buyer_total_price || 0) <= 0) {
ElMessage.warning('请填写有效的加价后比例或价格')
return
}
adjustingPrice.value = true
try {
const updated = await adjustListingReviewPrice(priceAdjustListing.value.id, payload)
replaceListing(updated)
priceAdjustListing.value = null
ElMessage.success('价格已更新')
} catch (error) {
ElMessage.error(readError(error, '价格调整失败'))
} finally {
adjustingPrice.value = false
}
}
function appendRejectReason(reason: string) {
const current = rejectReason.value.trim()
if (!current) {
@@ -172,6 +231,137 @@ function assetNumberText(row: Listing, key: string) {
return value > 0 ? quantity(value) : '-'
}
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 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 sellerTotalPrice(row: Listing) {
const value = breakdownNumber(row, 'seller_total_price')
if (value > 0) return value
const fallback = Number(row.price || 0) - getListingConsumablePrice(row)
return fallback > 0 ? fallback : Number(row.price || 0)
}
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 : Number(row.price || 0)
}
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 platformMarkupAmount(row: Listing) {
const value = breakdownNumber(row, 'platform_markup_amount')
if (value > 0) return value
return Math.max(0, buyerTotalPrice(row) - sellerTotalPrice(row))
}
function priceRuleType(row: Listing) {
const value = priceBreakdown(row).platform_rule_type
return typeof value === 'string' && value ? value : 'none'
}
function priceRuleLabel(row: Listing) {
const labels: Record<string, string> = {
fixed_markup: '固定加价',
ratio_subtract: '比例加价',
external_upload: '外部上传价',
admin_adjusted: '后台已调整',
none: '无加价',
}
return labels[priceRuleType(row)] || priceRuleType(row)
}
function breakdownText(row: Listing, key: string) {
const value = priceBreakdown(row)[key]
return typeof value === 'string' && value.trim() ? value.trim() : ''
}
function adminAdjustReason(row: Listing) {
return breakdownText(row, 'admin_adjust_reason')
}
function priceRuleText(row: Listing) {
const markup = platformMarkupAmount(row)
if (priceRuleType(row) === 'admin_adjusted') {
const reason = adminAdjustReason(row)
return reason ? `原因:${reason}` : `${priceRuleLabel(row)} · 人工确认`
}
if (priceRuleType(row) === 'ratio_subtract') {
return `${priceRuleLabel(row)} · ${ratioText(sellerRatio(row))}${ratioText(buyerRatio(row))}`
}
if (markup > 0) return `${priceRuleLabel(row)} · +${money(markup)}`
return priceRuleLabel(row)
}
function dailyLossText(row: Listing) {
const value = readAssetNumber(row, 'daily_loss_m')
return value > 0 ? `${quantity(value)}M` : '未上传'
@@ -416,26 +606,39 @@ function readError(error: unknown, fallback: string) {
</div>
</section>
<section class="metric-grid review-metrics">
<div class="metric-card">
<span>哈夫币</span>
<strong>{{ formatHafCoinM(getCoinWan(selectedListing)) }}</strong>
<section class="review-panel price-review-panel">
<div class="panel-title price-panel-title">
<div>
<h3>价格审核</h3>
<p>核对卖家提交价格平台加价规则和最终买家展示价</p>
</div>
<el-button type="primary" plain @click="openPriceAdjust(selectedListing)">调整价格</el-button>
</div>
<div class="metric-card">
<span>回收比例</span>
<strong>{{ formatRatio(selectedListing) }}</strong>
</div>
<div class="metric-card">
<span>回收租金</span>
<strong>{{ money(selectedListing.price) }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
</div>
<div class="metric-card">
<span>每日损耗</span>
<strong>{{ dailyLossText(selectedListing) }}</strong>
<div class="price-decision-grid">
<div class="price-decision-card seller">
<span>卖家发布</span>
<strong>{{ money(sellerTotalPrice(selectedListing)) }}</strong>
<p>发布比例 {{ ratioText(sellerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(sellerCoinBasePrice(selectedListing)) }} / 物品 {{ money(getListingConsumablePrice(selectedListing)) }}</small>
</div>
<div class="price-decision-card rule">
<span>当前加价规则</span>
<strong>{{ priceRuleLabel(selectedListing) }}</strong>
<p>{{ priceRuleText(selectedListing) }}</p>
<small>平台加价 {{ money(platformMarkupAmount(selectedListing)) }}</small>
</div>
<div class="price-decision-card buyer">
<span>加价后展示</span>
<strong>{{ money(buyerTotalPrice(selectedListing)) }}</strong>
<p>加价后比例 {{ ratioText(buyerRatio(selectedListing)) }}</p>
<small>纯币 {{ money(buyerCoinBasePrice(selectedListing)) }} / 总价含物品</small>
</div>
<div class="price-decision-card deposit">
<span>押金与损耗</span>
<strong>{{ money(selectedListing.deposit_amount) }}</strong>
<p>每日损耗 {{ dailyLossText(selectedListing) }}</p>
<small>哈夫币 {{ formatHafCoinM(getCoinWan(selectedListing)) }}</small>
</div>
</div>
</section>
@@ -534,6 +737,45 @@ function readError(error: unknown, fallback: string) {
</template>
</el-dialog>
<el-dialog :model-value="!!priceAdjustListing" title="调整审核价格" width="560px" @update:model-value="priceAdjustListing = null">
<div v-if="priceAdjustListing" class="dialog-body price-adjust-dialog">
<div class="adjust-current">
<div>
<span>卖家发布价</span>
<strong>{{ money(sellerTotalPrice(priceAdjustListing)) }}</strong>
</div>
<div>
<span>调整后买家价</span>
<strong class="preview-value">{{ previewMoney(priceAdjustPreview?.buyerTotalPrice || 0) }}</strong>
</div>
<div>
<span>调整后买家比例</span>
<strong class="preview-value">{{ ratioText(priceAdjustPreview?.buyerRatio || 0) }}</strong>
</div>
</div>
<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>
<label v-if="priceAdjustMode === 'ratio'" class="adjust-field">
<span>加价后比例</span>
<el-input-number v-model="priceAdjustForm.buyer_ratio" :min="0" :step="0.1" :controls="false" />
</label>
<label v-else class="adjust-field">
<span>加价后价格</span>
<el-input-number v-model="priceAdjustForm.buyer_total_price" :min="0" :step="1" :controls="false" />
</label>
<label class="adjust-field">
<span>调价原因</span>
<el-input v-model="priceAdjustForm.reason" type="textarea" :rows="3" placeholder="可填写调价原因,便于审计追踪" />
</label>
</div>
<template #footer>
<el-button @click="priceAdjustListing = null">取消</el-button>
<el-button type="primary" :loading="adjustingPrice" @click="handleSavePriceAdjust">保存调整</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="860px" @update:model-value="evidenceListing = null">
<div v-if="evidenceListing" class="dialog-body">
<p><strong>{{ evidenceListing.title }}</strong></p>
@@ -580,7 +822,10 @@ function readError(error: unknown, fallback: string) {
.queue-footer span,
.panel-title span,
.inventory-grid span,
.resource-table span {
.resource-table span,
.price-decision-card span,
.adjust-current span,
.adjust-field span {
color: #8f9bba;
font-size: 12px;
font-weight: 600;
@@ -769,6 +1014,73 @@ function readError(error: unknown, fallback: string) {
margin-bottom: 14px;
}
.price-panel-title p {
margin: 5px 0 0;
color: #8f9bba;
font-size: 12px;
font-weight: 600;
}
.price-decision-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 12px;
}
.price-decision-card {
display: grid;
gap: 7px;
min-width: 0;
min-height: 128px;
border: 1px solid #e8ecf1;
border-radius: 8px;
background: #f8f9fe;
padding: 14px;
}
.price-decision-card strong {
color: #1b2559;
font-size: 24px;
font-weight: 900;
line-height: 1.1;
}
.price-decision-card p,
.price-decision-card small {
margin: 0;
color: #4b5563;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.price-decision-card small {
color: #8f9bba;
}
.price-decision-card.buyer {
border-color: #bfdbfe;
background: #eff6ff;
}
.price-decision-card.buyer strong {
color: #2563eb;
}
.price-decision-card.rule {
border-color: #fed7aa;
background: #fff7ed;
}
.price-decision-card.rule strong {
color: #ea580c;
}
.price-decision-card.deposit {
border-color: #e0e7ff;
background: #f5f7ff;
}
.detail-list {
display: grid;
gap: 10px;
@@ -919,6 +1231,70 @@ function readError(error: unknown, fallback: string) {
margin: 12px 0;
}
.price-adjust-dialog {
display: grid;
gap: 14px;
}
.adjust-current {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.adjust-current div {
display: grid;
gap: 5px;
border-radius: 8px;
background: #f8f9fe;
padding: 12px;
}
.adjust-current strong {
color: #1b2559;
font-size: 18px;
font-weight: 900;
}
.adjust-current .preview-value {
color: #2563eb;
}
.adjust-mode {
width: 100%;
}
.adjust-mode :deep(.el-radio-button) {
width: 50%;
}
.adjust-mode :deep(.el-radio-button__inner) {
width: 100%;
border-color: #d7deea;
font-weight: 800;
}
.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
border-color: #2563eb;
background: #2563eb;
box-shadow: -1px 0 0 0 #2563eb;
}
.adjust-field {
display: grid;
gap: 8px;
}
.adjust-field > span {
color: #52607a;
font-size: 12px;
font-weight: 800;
}
.adjust-field :deep(.el-input-number) {
width: 100%;
}
.empty-detail {
min-height: 420px;
display: grid;
@@ -943,11 +1319,15 @@ function readError(error: unknown, fallback: string) {
@media (max-width: 900px) {
.review-summary,
.review-metrics,
.price-decision-grid,
.review-grid {
grid-template-columns: 1fr;
}
.adjust-current {
grid-template-columns: 1fr;
}
.hero-title {
flex-direction: column;
}