626 lines
18 KiB
Vue
626 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { EditPen } from '@element-plus/icons-vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
import {
|
|
fetchAdminPickup,
|
|
updateAdminPickupProfit,
|
|
type AdminPickup,
|
|
} from '@/features/admin/api/adminPickup'
|
|
import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot'
|
|
import { adminPath } from '@/shared/utils/adminPath'
|
|
import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay'
|
|
import {
|
|
centToYuan,
|
|
formatCentWithSymbol,
|
|
formatMoneyWithSymbol,
|
|
yuanToCent,
|
|
} from '@/shared/utils/money'
|
|
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
|
import { formatDateTime } from '@/shared/utils/time'
|
|
|
|
interface SnapshotResource {
|
|
key: string
|
|
label: string
|
|
price: string
|
|
mode: string
|
|
quantity: number
|
|
amount: number
|
|
}
|
|
|
|
const route = useRoute()
|
|
const loading = ref(false)
|
|
const pickup = ref<AdminPickup | null>(null)
|
|
const profitDialogVisible = ref(false)
|
|
const profitSaving = ref(false)
|
|
const profitForm = reactive({ profit_amount: 0, reason: '' })
|
|
|
|
const listingCode = computed(() =>
|
|
pickup.value ? formatListingNo(pickup.value.listing_no, pickup.value.listing_id) : '-'
|
|
)
|
|
const canEditProfit = computed(() => pickup.value?.status === 'picking_up')
|
|
const snapshot = computed(() => normalizeRecord(pickup.value?.account_snapshot))
|
|
const assetSummary = computed(() => normalizeRecord(snapshot.value?.asset_summary))
|
|
const priceBreakdown = computed(() => normalizeRecord(assetSummary.value?.price_breakdown))
|
|
const snapshotText = computed(() =>
|
|
snapshot.value ? JSON.stringify(snapshot.value, null, 2) : '暂无账号快照'
|
|
)
|
|
const snapshotHafCoinM = computed(() =>
|
|
snapshot.value ? quantity(readNumber(snapshot.value.haf_coin_amount) / 1000000) : '-'
|
|
)
|
|
const snapshotSeasonTags = computed(() => {
|
|
const tags = snapshot.value?.season_tags
|
|
return Array.isArray(tags) ? tags.map(item => String(item)).filter(Boolean) : []
|
|
})
|
|
const snapshotResources = computed(() => readSnapshotResources(assetSummary.value))
|
|
const accountSnapshotItems = computed(() => {
|
|
const row = snapshot.value
|
|
if (!row) return []
|
|
return [
|
|
{ label: '账号ID', value: displayValue(row.account_id) },
|
|
{ label: '账号标题', value: displayValue(row.title) },
|
|
{ label: '游戏名称', value: formatGameName(row.game_name, '-') },
|
|
{ label: '所在区服', value: displayValue(row.server_region) },
|
|
{ label: '登录平台', value: displayValue(row.login_platform) },
|
|
{ label: '段位等级', value: displayValue(row.rank_level) },
|
|
].filter(item => item.value !== '-')
|
|
})
|
|
const moneySplitRows = computed(() => {
|
|
const row = pickup.value
|
|
if (!row) return []
|
|
const ownerCoinBasePriceCent = readBreakdownCent('seller_coin_base_price')
|
|
const ownerLossPriceCent =
|
|
readBreakdownCent('consumable_price') ?? fallbackOwnerLossCent(ownerCoinBasePriceCent)
|
|
const offlineTotalCent =
|
|
row.settle_amount_cent > 0 ? row.settle_amount_cent + row.profit_amount_cent : null
|
|
const rows = [
|
|
{ label: '网站售价', amountCent: row.listing_price_cent, tone: '' },
|
|
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent, tone: '' },
|
|
{ label: '号主损耗', amountCent: ownerLossPriceCent, tone: '' },
|
|
{ label: '号主价合计', amountCent: row.owner_price_cent, tone: 'subtotal' },
|
|
{ label: '网站加价', amountCent: row.website_profit_cent, tone: '' },
|
|
{ label: '线下利润', amountCent: row.profit_amount_cent, tone: 'profit' },
|
|
{ label: '结算给号主', amountCent: row.settle_amount_cent, tone: '' },
|
|
]
|
|
if (offlineTotalCent !== null) {
|
|
rows.push({ label: '线下成交合计', amountCent: offlineTotalCent, tone: 'total' })
|
|
}
|
|
return rows
|
|
})
|
|
const breakdownRows = computed(() => {
|
|
const row = priceBreakdown.value
|
|
if (!row) return []
|
|
return [
|
|
{ label: '回收比例', value: ratioText(readNumber(row.seller_ratio)) },
|
|
{ label: '售卖比例', value: ratioText(readNumber(row.buyer_ratio)) },
|
|
{ label: '参考比例', value: ratioText(readNumber(row.seller_reference_ratio)) },
|
|
{ label: '加速出号比例', value: ratioText(readNumber(row.accelerated_sale_ratio)) },
|
|
{ label: '定价规则', value: displayValue(row.platform_rule_type) },
|
|
{ label: '调价原因', value: displayValue(row.admin_adjust_reason) },
|
|
{ label: '调价时间', value: displayValue(row.admin_adjusted_at) },
|
|
].filter(item => item.value !== '-')
|
|
})
|
|
|
|
onMounted(loadPickup)
|
|
|
|
async function loadPickup() {
|
|
loading.value = true
|
|
try {
|
|
pickup.value = await fetchAdminPickup(String(route.params.id))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openProfitDialog() {
|
|
if (!pickup.value) return
|
|
profitForm.profit_amount = centToYuan(pickup.value.profit_amount_cent)
|
|
profitForm.reason = ''
|
|
profitDialogVisible.value = true
|
|
}
|
|
|
|
async function handleUpdateProfit() {
|
|
if (!pickup.value) return
|
|
if (profitForm.profit_amount < 0) {
|
|
ElMessage.warning('线下利润不能为负数')
|
|
return
|
|
}
|
|
profitSaving.value = true
|
|
try {
|
|
pickup.value = await updateAdminPickupProfit(pickup.value.id, {
|
|
profit_amount_cent: yuanToCent(profitForm.profit_amount),
|
|
reason: profitForm.reason,
|
|
})
|
|
ElMessage.success('线下利润已更新')
|
|
profitDialogVisible.value = false
|
|
} catch (e: unknown) {
|
|
ElMessage.error(errorMessage(e) || '修改失败')
|
|
} finally {
|
|
profitSaving.value = false
|
|
}
|
|
}
|
|
|
|
function statusType(status: string) {
|
|
if (status === 'picking_up') return 'warning'
|
|
if (status === 'completed') return 'success'
|
|
if (status === 'cancelled') return 'info'
|
|
return ''
|
|
}
|
|
|
|
function ratioText(value: number) {
|
|
const ratio = Number(value || 0)
|
|
if (ratio <= 0) return '-'
|
|
const rounded = Math.round(ratio * 100) / 100
|
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`
|
|
}
|
|
|
|
function displayValue(value: unknown) {
|
|
if (value === undefined || value === null || value === '') return '-'
|
|
return String(value)
|
|
}
|
|
|
|
function errorMessage(e: unknown) {
|
|
if (typeof e === 'object' && e !== null && 'message' in e) {
|
|
return String((e as { message?: unknown }).message)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function readBreakdownCent(key: string) {
|
|
const value = readNumber(priceBreakdown.value?.[key])
|
|
return value > 0 ? Math.round(value * 100) : null
|
|
}
|
|
|
|
function fallbackOwnerLossCent(ownerCoinBasePriceCent: number | null) {
|
|
const row = pickup.value
|
|
if (!row || ownerCoinBasePriceCent === null) return null
|
|
return Math.max(row.owner_price_cent - ownerCoinBasePriceCent, 0)
|
|
}
|
|
|
|
function formatNullableCent(value: number | null | undefined) {
|
|
if (value === null || value === undefined) return '-'
|
|
return formatCentWithSymbol(value)
|
|
}
|
|
|
|
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
|
|
if (typeof value === 'string' && value.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return typeof parsed === 'object' && parsed !== null ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readSnapshotResources(summary: Record<string, unknown> | null): SnapshotResource[] {
|
|
const resources = summary?.resources
|
|
if (!Array.isArray(resources)) return []
|
|
return resources
|
|
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
|
|
.map(item => {
|
|
const key = String(item.key || item.label || '')
|
|
const label = String(item.label || key || '额外消耗品')
|
|
const price = String(item.price || '')
|
|
const mode = String(item.mode || '收费')
|
|
const count = readNumber(item.quantity)
|
|
return {
|
|
key,
|
|
label,
|
|
price,
|
|
mode,
|
|
quantity: count,
|
|
amount: mode === '收费' ? count * readUnitPrice(price) : 0,
|
|
}
|
|
})
|
|
.filter(item => item.key && item.quantity > 0)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page" v-loading="loading">
|
|
<div v-if="pickup" class="page-header-row">
|
|
<div class="page-header">
|
|
<p class="eyebrow">{{ pickup.pickup_no }}</p>
|
|
<h1>提号详情</h1>
|
|
<p>
|
|
商品编号 {{ listingCode }} · {{ pickup.account_title }} · {{ pickup.server_region }} /
|
|
{{ pickup.login_platform }}
|
|
</p>
|
|
</div>
|
|
<div class="toolbar-actions">
|
|
<el-button
|
|
v-if="canEditProfit"
|
|
type="primary"
|
|
plain
|
|
:icon="EditPen"
|
|
@click="openProfitDialog"
|
|
>
|
|
修改利润
|
|
</el-button>
|
|
<RouterLink :to="adminPath('pickup')">
|
|
<el-button>返回列表</el-button>
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="pickup" class="metric-grid pickup-metrics">
|
|
<div class="metric-card">
|
|
<span>提号状态</span>
|
|
<strong>{{ pickupStatusLabel(pickup.status) }}</strong>
|
|
<small>创建 {{ formatDateTime(pickup.created_at) }}</small>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>线下利润</span>
|
|
<strong>{{ formatCentWithSymbol(pickup.profit_amount_cent) }}</strong>
|
|
<small>财务统计使用该金额</small>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>结算金额</span>
|
|
<strong>{{ formatCentWithSymbol(pickup.settle_amount_cent) }}</strong>
|
|
<small>{{ pickup.completed_at ? formatDateTime(pickup.completed_at) : '待结算' }}</small>
|
|
</div>
|
|
<div class="metric-card">
|
|
<span>比例</span>
|
|
<strong>{{ ratioText(pickup.seller_ratio) }} / {{ ratioText(pickup.buyer_ratio) }}</strong>
|
|
<small>回收 / 售卖</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="pickup" class="pickup-detail-grid">
|
|
<section class="dashboard-panel detail-panel">
|
|
<div class="panel-heading">
|
|
<h2>提号概览</h2>
|
|
<el-tag :type="statusType(pickup.status)" effect="light">
|
|
{{ pickupStatusLabel(pickup.status) }}
|
|
</el-tag>
|
|
</div>
|
|
<dl class="detail-list">
|
|
<div>
|
|
<dt>提号编号</dt>
|
|
<dd>{{ pickup.pickup_no }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>上架编号</dt>
|
|
<dd>{{ listingCode }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>账号ID</dt>
|
|
<dd>{{ pickup.account_id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>号主</dt>
|
|
<dd>{{ pickup.owner_phone || pickup.owner_id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>交易渠道</dt>
|
|
<dd>{{ pickup.platform || '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>店铺名称</dt>
|
|
<dd>{{ pickup.shop_name || '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>管理员ID</dt>
|
|
<dd>{{ pickup.admin_id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>创建时间</dt>
|
|
<dd>{{ formatDateTime(pickup.created_at) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>完成时间</dt>
|
|
<dd>{{ formatDateTime(pickup.completed_at, '未完成') }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section class="dashboard-panel detail-panel">
|
|
<div class="panel-heading">
|
|
<h2>资金拆分</h2>
|
|
<span class="panel-subtitle">价格快照来自创建时</span>
|
|
</div>
|
|
<div class="money-list">
|
|
<div
|
|
v-for="row in moneySplitRows"
|
|
:key="row.label"
|
|
class="money-row"
|
|
:class="`is-${row.tone || 'normal'}`"
|
|
>
|
|
<span>{{ row.label }}</span>
|
|
<strong>{{ formatNullableCent(row.amountCent) }}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dashboard-panel detail-panel">
|
|
<div class="panel-heading">
|
|
<h2>备注</h2>
|
|
</div>
|
|
<dl class="detail-list">
|
|
<div class="wide">
|
|
<dt>创建备注</dt>
|
|
<dd>{{ pickup.remark || '-' }}</dd>
|
|
</div>
|
|
<div class="wide">
|
|
<dt>完成备注</dt>
|
|
<dd>{{ pickup.complete_remark || '-' }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section v-if="breakdownRows.length" class="dashboard-panel detail-panel">
|
|
<div class="panel-heading">
|
|
<h2>价格拆分</h2>
|
|
<span class="panel-subtitle">来自账号快照</span>
|
|
</div>
|
|
<dl class="detail-list">
|
|
<div v-for="row in breakdownRows" :key="row.label">
|
|
<dt>{{ row.label }}</dt>
|
|
<dd>{{ row.value }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section class="dashboard-panel detail-panel pickup-wide-panel">
|
|
<div class="panel-heading">
|
|
<h2>账号快照</h2>
|
|
<span class="panel-subtitle">哈夫币 {{ snapshotHafCoinM }}M</span>
|
|
</div>
|
|
<dl v-if="accountSnapshotItems.length" class="detail-list snapshot-detail-list">
|
|
<div v-for="item in accountSnapshotItems" :key="item.label">
|
|
<dt>{{ item.label }}</dt>
|
|
<dd>{{ item.value }}</dd>
|
|
</div>
|
|
</dl>
|
|
<div v-if="snapshotSeasonTags.length" class="snapshot-tags">
|
|
<el-tag v-for="tag in snapshotSeasonTags" :key="tag" size="small" effect="plain">
|
|
{{ tag }}
|
|
</el-tag>
|
|
</div>
|
|
<div v-if="snapshotResources.length" class="snapshot-resources">
|
|
<div v-for="resource in snapshotResources" :key="resource.key" class="resource-item">
|
|
<strong>{{ resource.label }}</strong>
|
|
<span>
|
|
{{ quantity(resource.quantity) }} · {{ resource.price || resource.mode }}
|
|
<template v-if="resource.amount > 0">
|
|
· {{ formatMoneyWithSymbol(resource.amount) }}</template
|
|
>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<pre class="snapshot-json">{{ snapshotText }}</pre>
|
|
</section>
|
|
</div>
|
|
|
|
<el-dialog v-model="profitDialogVisible" title="修改线下利润" width="460px">
|
|
<el-form label-width="110px">
|
|
<el-form-item label="线下利润(元)" required>
|
|
<el-input-number
|
|
v-model="profitForm.profit_amount"
|
|
:min="0"
|
|
:step="1"
|
|
:precision="2"
|
|
controls-position="right"
|
|
style="width: 100%"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="修改原因">
|
|
<el-input
|
|
v-model="profitForm.reason"
|
|
type="textarea"
|
|
:rows="3"
|
|
maxlength="255"
|
|
show-word-limit
|
|
placeholder="可选"
|
|
/>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="profitDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="profitSaving" @click="handleUpdateProfit">
|
|
保存
|
|
</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.pickup-metrics {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.pickup-metrics .metric-card strong {
|
|
font-size: 20px;
|
|
line-height: 1.25;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.metric-card small {
|
|
display: block;
|
|
margin-top: 8px;
|
|
color: #8f9bba;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.pickup-detail-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
|
gap: 20px;
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.detail-panel {
|
|
min-width: 0;
|
|
}
|
|
|
|
.pickup-wide-panel {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.panel-heading {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.panel-heading h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.panel-subtitle {
|
|
color: #8f9bba;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
text-align: right;
|
|
}
|
|
|
|
.detail-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px 18px;
|
|
margin: 0;
|
|
}
|
|
|
|
.detail-list div {
|
|
min-width: 0;
|
|
border-top: 1px solid #f0f2f5;
|
|
padding-top: 10px;
|
|
}
|
|
|
|
.detail-list .wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.detail-list dt {
|
|
margin-bottom: 5px;
|
|
color: #8f9bba;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.detail-list dd {
|
|
margin: 0;
|
|
color: #1b2559;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
line-height: 1.5;
|
|
overflow-wrap: anywhere;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.money-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.money-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
border-top: 1px solid #f0f2f5;
|
|
padding-top: 9px;
|
|
color: #52616f;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.money-row strong {
|
|
color: #1b2559;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.money-row.is-subtotal {
|
|
border-top-color: #d8e2f0;
|
|
color: #1b2559;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.money-row.is-profit strong {
|
|
color: #0f766e;
|
|
}
|
|
|
|
.money-row.is-total {
|
|
margin-top: 4px;
|
|
border-top-color: #bfdbfe;
|
|
color: #1b2559;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.money-row.is-total strong {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.snapshot-detail-list {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.snapshot-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.snapshot-resources {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.resource-item {
|
|
display: grid;
|
|
gap: 4px;
|
|
border: 1px solid #eef2f7;
|
|
border-radius: 8px;
|
|
background: #fbfcff;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.resource-item strong {
|
|
color: #1b2559;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.resource-item span {
|
|
color: #52616f;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.snapshot-json {
|
|
overflow-x: auto;
|
|
max-height: 360px;
|
|
margin: 0;
|
|
border-radius: 8px;
|
|
background: #0f172a;
|
|
color: #dbeafe;
|
|
padding: 14px;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.pickup-detail-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.pickup-wide-panel {
|
|
grid-column: auto;
|
|
}
|
|
|
|
.detail-list {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|