支持号主与租客查看自己的发布/订单详情
新增卖家商品详情页;订单详情底部增加可折叠账号快照,已完成订单展示结账明细。
This commit is contained in:
@@ -231,10 +231,43 @@ func sanitizeOrderSnapshot(snapshot *datatypes.JSON, role string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
breakdown, _ := summary["price_breakdown"].(map[string]any)
|
// price_breakdown 含双方比例与加价明细,仅把当前角色可见的价格字段摊平到 summary,再删除原对象。
|
||||||
if role == "owner" && breakdown != nil {
|
if breakdown, ok := summary["price_breakdown"].(map[string]any); ok && breakdown != nil {
|
||||||
if sellerRatio := readJSONNumber(breakdown["seller_ratio"]); sellerRatio > 0 {
|
switch role {
|
||||||
summary["publish_ratio"] = sellerRatio
|
case "owner":
|
||||||
|
if v := readJSONNumber(breakdown["seller_ratio"]); v > 0 {
|
||||||
|
summary["publish_ratio"] = v
|
||||||
|
summary["display_ratio"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["seller_reference_ratio"]); v > 0 {
|
||||||
|
summary["reference_ratio"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["seller_total_price"]); v > 0 {
|
||||||
|
summary["display_total_price"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["seller_coin_base_price"]); v > 0 {
|
||||||
|
summary["display_coin_base_price"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["accelerated_sale_ratio"]); v > 0 {
|
||||||
|
summary["accelerated_sale_ratio"] = v
|
||||||
|
}
|
||||||
|
case "renter":
|
||||||
|
if v := readJSONNumber(breakdown["buyer_ratio"]); v > 0 {
|
||||||
|
summary["publish_ratio"] = v
|
||||||
|
summary["display_ratio"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["buyer_total_price"]); v > 0 {
|
||||||
|
summary["display_total_price"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["buyer_coin_base_price"]); v > 0 {
|
||||||
|
summary["display_coin_base_price"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["consumable_price"]); v > 0 {
|
||||||
|
summary["consumable_price"] = v
|
||||||
|
}
|
||||||
|
if v := readJSONNumber(breakdown["daily_loss_ratio_adjustment"]); v != 0 {
|
||||||
|
summary["daily_loss_ratio_adjustment"] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(summary, "price_breakdown")
|
delete(summary, "price_breakdown")
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { Order } from '@/features/orders/api/orders'
|
||||||
|
import {
|
||||||
|
getSnapshotHafCoinM,
|
||||||
|
quantity,
|
||||||
|
readAssetSummary,
|
||||||
|
readSnapshot,
|
||||||
|
readSnapshotResources,
|
||||||
|
} from '@/features/orders/composables/useOrderSnapshot'
|
||||||
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
order: Order
|
||||||
|
variant?: 'desktop' | 'mobile'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'desktop' }
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 默认折叠 */
|
||||||
|
const expanded = ref(false)
|
||||||
|
const mobileActive = ref<string[]>([])
|
||||||
|
|
||||||
|
const snapshot = computed(() => readSnapshot(props.order))
|
||||||
|
const asset = computed(() => readAssetSummary(props.order) || {})
|
||||||
|
const resources = computed(() => readSnapshotResources(props.order))
|
||||||
|
const coinM = computed(() => getSnapshotHafCoinM(props.order))
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScalar(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '--'
|
||||||
|
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isFinite(value)) return '--'
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100)
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') return value.trim() || '--'
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRatioNumber(value: unknown): string {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '--'
|
||||||
|
const rounded = Math.round(n * 10) / 10
|
||||||
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOnlineTime(value: unknown): string {
|
||||||
|
const row = asRecord(value)
|
||||||
|
if (!row) return '--'
|
||||||
|
const start = String(row.start || '')
|
||||||
|
const end = String(row.end || '')
|
||||||
|
if (!start && !end) return '--'
|
||||||
|
if (start === '全天' || end === '全天' || (start === '00:00' && end === '23:59')) return '全天'
|
||||||
|
if (start && end) return `${start.replace(':00', '')}-${end.replace(':00', '')}点`
|
||||||
|
return [start, end].filter(Boolean).join(' ~ ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRows = computed(() => {
|
||||||
|
const s = snapshot.value || {}
|
||||||
|
const a = asset.value
|
||||||
|
const ratio = a.display_ratio ?? a.publish_ratio
|
||||||
|
const regions = asStringList(a.common_regions)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '商品标题', value: formatScalar(s.title || props.order.title) },
|
||||||
|
{ label: '商品编号', value: formatScalar(s.listing_no || props.order.listing_no) },
|
||||||
|
{ label: '区服', value: formatScalar(s.server_region || props.order.server_region) },
|
||||||
|
{ label: '上号方式', value: formatScalar(s.login_platform || props.order.login_platform) },
|
||||||
|
{ label: '游戏段位', value: formatScalar(s.rank_level) },
|
||||||
|
{ label: '游戏名称', value: formatScalar(s.game_name) },
|
||||||
|
{ label: '纯币', value: coinM.value > 0 ? `${quantity(coinM.value)}M` : '--' },
|
||||||
|
{ label: 'M单价/比例', value: formatRatioNumber(ratio) },
|
||||||
|
{
|
||||||
|
label: '日损耗',
|
||||||
|
value: Number(a.daily_loss_m) > 0 ? `${formatScalar(a.daily_loss_m)}M/天` : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '烽火等级',
|
||||||
|
value: a.fire_level != null && a.fire_level !== '' ? `${formatScalar(a.fire_level)}级` : '--',
|
||||||
|
},
|
||||||
|
{ label: '绝密KD', value: formatScalar(a.secret_kd) },
|
||||||
|
{ label: '体力等级', value: formatScalar(a.stamina_level) },
|
||||||
|
{ label: '负重等级', value: formatScalar(a.load_level) },
|
||||||
|
{ label: '赛季保险', value: formatScalar(a.season_insurance) },
|
||||||
|
{ label: '方便上号时段', value: formatOnlineTime(a.online_time) },
|
||||||
|
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
|
||||||
|
{ label: '人脸归属', value: formatScalar(a.face_owner) },
|
||||||
|
{ label: '是否解锁赛季', value: formatScalar(a.unlock_said) },
|
||||||
|
{
|
||||||
|
label: '封禁记录',
|
||||||
|
value: formatScalar(a.ban_record) === '--' ? '无' : formatScalar(a.ban_record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '备注',
|
||||||
|
value: formatScalar(s.description || a.remark || a.note),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 未在主表列出、但仍可展示的扁平字段 */
|
||||||
|
const extraRows = computed(() => {
|
||||||
|
const known = new Set([
|
||||||
|
'face_owner',
|
||||||
|
'secret_kd',
|
||||||
|
'fire_level',
|
||||||
|
'daily_loss_m',
|
||||||
|
'publish_ratio',
|
||||||
|
'display_ratio',
|
||||||
|
'reference_ratio',
|
||||||
|
'display_total_price',
|
||||||
|
'display_coin_base_price',
|
||||||
|
'consumable_price',
|
||||||
|
'daily_loss_ratio_adjustment',
|
||||||
|
'accelerated_sale_ratio',
|
||||||
|
'seller_ratio',
|
||||||
|
'buyer_ratio',
|
||||||
|
'seller_total_price',
|
||||||
|
'buyer_total_price',
|
||||||
|
'seller_coin_base_price',
|
||||||
|
'buyer_coin_base_price',
|
||||||
|
'seller_reference_ratio',
|
||||||
|
'platform_markup_amount',
|
||||||
|
'platform_rule_type',
|
||||||
|
'season_insurance',
|
||||||
|
'stamina_level',
|
||||||
|
'load_level',
|
||||||
|
'resources',
|
||||||
|
'skin_groups',
|
||||||
|
'screenshot_groups',
|
||||||
|
'online_time',
|
||||||
|
'unlock_said',
|
||||||
|
'ban_record',
|
||||||
|
'common_regions',
|
||||||
|
'remark',
|
||||||
|
'note',
|
||||||
|
'price_breakdown',
|
||||||
|
])
|
||||||
|
const rows: { label: string; value: string }[] = []
|
||||||
|
for (const [key, value] of Object.entries(asset.value)) {
|
||||||
|
if (known.has(key)) continue
|
||||||
|
if (value === null || value === undefined || value === '') continue
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
// 复杂对象单独区块处理;其余跳过避免噪音
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows.push({ label: key, value: formatScalar(value) })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
const skinGroups = computed(() => {
|
||||||
|
const groups = asset.value.skin_groups
|
||||||
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
melee: '近战皮肤',
|
||||||
|
operator: '干员皮肤',
|
||||||
|
operatorGold: '干员金皮',
|
||||||
|
operatorRed: '干员红皮',
|
||||||
|
weapon: '武器皮肤',
|
||||||
|
}
|
||||||
|
return Object.entries(groups as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
title: titles[key] || key,
|
||||||
|
options: asStringList(value),
|
||||||
|
}))
|
||||||
|
.filter(g => g.options.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const screenshots = computed(() => {
|
||||||
|
const groups = asset.value.screenshot_groups
|
||||||
|
if (typeof groups === 'object' && groups !== null) {
|
||||||
|
const slots = [
|
||||||
|
{ key: 'coin', label: '纯币截图' },
|
||||||
|
{ key: 'gameId', label: '游戏ID截图' },
|
||||||
|
{ key: 'totalAsset', label: '总资产截图' },
|
||||||
|
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
|
||||||
|
{ key: 'skin', label: '皮肤截图' },
|
||||||
|
]
|
||||||
|
return slots.flatMap(slot => {
|
||||||
|
const urls = (groups as Record<string, unknown>)[slot.key]
|
||||||
|
if (!Array.isArray(urls)) return []
|
||||||
|
const valid = urls.filter((u): u is string => typeof u === 'string' && Boolean(u))
|
||||||
|
return valid.map((url, i) => ({
|
||||||
|
label: valid.length > 1 ? `${slot.label}${i + 1}` : slot.label,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const list = snapshot.value?.screenshot_urls
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return list
|
||||||
|
.filter((u): u is string => typeof u === 'string' && Boolean(u))
|
||||||
|
.map((url, i) => ({ label: `截图${i + 1}`, url }))
|
||||||
|
})
|
||||||
|
const screenshotUrls = computed(() => screenshots.value.map(s => s.url))
|
||||||
|
|
||||||
|
const hasContent = computed(() => Boolean(snapshot.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Mobile:默认折叠 -->
|
||||||
|
<section v-if="props.variant === 'mobile'" class="card-section">
|
||||||
|
<van-collapse v-model="mobileActive" :border="false">
|
||||||
|
<van-collapse-item name="snapshot" title="订单账号信息" :label="hasContent ? '下单时账号快照(点击展开)' : '暂无快照'">
|
||||||
|
<template v-if="hasContent">
|
||||||
|
<div class="kv-list">
|
||||||
|
<div v-for="row in primaryRows" :key="row.label" class="kv-row">
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<em>{{ row.value }}</em>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in extraRows" :key="'x-' + row.label" class="kv-row">
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<em>{{ row.value }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resources.length" class="sub-block">
|
||||||
|
<strong>额外消耗品</strong>
|
||||||
|
<div v-for="r in resources" :key="r.key" class="pill">
|
||||||
|
{{ r.label }} · {{ r.quantity }} · {{ r.mode || '收费' }}
|
||||||
|
<template v-if="r.price"> · {{ r.price }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="skinGroups.length" class="sub-block">
|
||||||
|
<strong>皮肤</strong>
|
||||||
|
<div v-for="g in skinGroups" :key="g.key" class="skin-group">
|
||||||
|
<p>{{ g.title }}</p>
|
||||||
|
<span v-for="skin in g.options" :key="skin" class="chip">{{ skin }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="screenshots.length" class="sub-block">
|
||||||
|
<strong>账号截图</strong>
|
||||||
|
<div class="shot-grid">
|
||||||
|
<figure v-for="(shot, index) in screenshots" :key="shot.url + index">
|
||||||
|
<AuthImage
|
||||||
|
:source="shot.url"
|
||||||
|
:alt="shot.label"
|
||||||
|
image-class="shot-img"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="screenshotUrls"
|
||||||
|
:preview-initial-index="index"
|
||||||
|
/>
|
||||||
|
<figcaption>{{ shot.label }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty">暂无账号快照(下单时未固化账号信息)</div>
|
||||||
|
</van-collapse-item>
|
||||||
|
</van-collapse>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Desktop:默认折叠 -->
|
||||||
|
<div v-else class="snapshot-section">
|
||||||
|
<button type="button" class="collapse-head" @click="expanded = !expanded">
|
||||||
|
<div>
|
||||||
|
<h2>订单账号信息</h2>
|
||||||
|
<span>下单时账号快照 · 仅本单双方可见 · {{ expanded ? '点击收起' : '点击展开' }}</span>
|
||||||
|
</div>
|
||||||
|
<em :class="{ open: expanded }">›</em>
|
||||||
|
</button>
|
||||||
|
<div v-show="expanded" class="collapse-body">
|
||||||
|
<template v-if="hasContent">
|
||||||
|
<div class="kv-grid">
|
||||||
|
<div v-for="row in primaryRows" :key="row.label" class="kv-item">
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<strong>{{ row.value }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in extraRows" :key="'x-' + row.label" class="kv-item">
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<strong>{{ row.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resources.length" class="sub-block">
|
||||||
|
<h3>额外消耗品</h3>
|
||||||
|
<div class="pill-row">
|
||||||
|
<span v-for="r in resources" :key="r.key" class="pill">
|
||||||
|
{{ r.label }} · {{ r.quantity }} · {{ r.mode || '收费' }}
|
||||||
|
<template v-if="r.price"> · {{ r.price }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="skinGroups.length" class="sub-block">
|
||||||
|
<h3>皮肤</h3>
|
||||||
|
<div v-for="g in skinGroups" :key="g.key" class="skin-group">
|
||||||
|
<p>{{ g.title }}</p>
|
||||||
|
<span v-for="skin in g.options" :key="skin" class="chip">{{ skin }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="screenshots.length" class="sub-block">
|
||||||
|
<h3>账号截图</h3>
|
||||||
|
<div class="shot-grid desktop">
|
||||||
|
<figure v-for="(shot, index) in screenshots" :key="shot.url + index">
|
||||||
|
<AuthImage
|
||||||
|
:source="shot.url"
|
||||||
|
:alt="shot.label"
|
||||||
|
image-class="shot-img"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="screenshotUrls"
|
||||||
|
:preview-initial-index="index"
|
||||||
|
/>
|
||||||
|
<figcaption>{{ shot.label }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty">暂无账号快照(下单时未固化账号信息)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.snapshot-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.collapse-head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 0;
|
||||||
|
background: #fbfcfe;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.collapse-head h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.collapse-head span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collapse-head em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.collapse-head em.open {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.collapse-body {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
.kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px 16px;
|
||||||
|
}
|
||||||
|
.kv-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.kv-item span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.kv-item strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.sub-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.sub-block h3,
|
||||||
|
.sub-block strong {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.pill-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.skin-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.skin-group p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 6px 6px 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.shot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.shot-grid.desktop {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
.shot-grid figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.shot-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.shot-grid figcaption {
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-section {
|
||||||
|
margin: 12px 12px 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.kv-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.kv-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.kv-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.kv-row span {
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kv-row em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.kv-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Orders 模块统一导出
|
// Orders 模块统一导出
|
||||||
export * from './api/orders'
|
export * from './api/orders'
|
||||||
export * from './composables/useOrderSnapshot'
|
export * from './composables/useOrderSnapshot'
|
||||||
|
export { default as OrderAccountSnapshot } from './components/OrderAccountSnapshot.vue'
|
||||||
export { default as OrderCheckoutSummary } from './components/OrderCheckoutSummary.vue'
|
export { default as OrderCheckoutSummary } from './components/OrderCheckoutSummary.vue'
|
||||||
export { default as OrderHandoffTimeline } from './components/OrderHandoffTimeline.vue'
|
export { default as OrderHandoffTimeline } from './components/OrderHandoffTimeline.vue'
|
||||||
export { default as OrderResourceUsageEditor } from './components/OrderResourceUsageEditor.vue'
|
export { default as OrderResourceUsageEditor } from './components/OrderResourceUsageEditor.vue'
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySe
|
|||||||
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
|
import { useOrderActions, CONFIRMABLE_ACTIONS } from '@/features/orders/composables/useOrderActions'
|
||||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
||||||
|
import { amountYuan, money } from '@/features/orders/composables/useOrderSnapshot'
|
||||||
import {
|
import {
|
||||||
amountYuan,
|
OrderAccountSnapshot,
|
||||||
money,
|
|
||||||
readAssetSummary as readOrderAssetSummary,
|
|
||||||
readSnapshot as readOrderSnapshot,
|
|
||||||
} from '@/features/orders/composables/useOrderSnapshot'
|
|
||||||
import {
|
|
||||||
OrderCheckoutSummary,
|
OrderCheckoutSummary,
|
||||||
OrderHandoffTimeline,
|
OrderHandoffTimeline,
|
||||||
OrderResourceUsageEditor,
|
OrderResourceUsageEditor,
|
||||||
@@ -116,20 +112,11 @@ const {
|
|||||||
onCreateDisputeSuccess: () => (showDisputePopup.value = false),
|
onCreateDisputeSuccess: () => (showDisputePopup.value = false),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mobile 独有 UI 状态:折叠面板、各类 popup 显隐。
|
// Mobile 独有 UI 状态:各类 popup 显隐。
|
||||||
const activeNames = ref<string[]>([])
|
|
||||||
const showDisputePopup = ref(false)
|
const showDisputePopup = ref(false)
|
||||||
const showCounterPopup = ref(false)
|
const showCounterPopup = ref(false)
|
||||||
const showRejectPopup = ref(false)
|
const showRejectPopup = ref(false)
|
||||||
|
|
||||||
// 账号快照展示(PC 不用,保留在本视图)。
|
|
||||||
function readSnapshot() {
|
|
||||||
return readOrderSnapshot(order.value) as Record<string, any> | null
|
|
||||||
}
|
|
||||||
function readAssetSummary() {
|
|
||||||
return readOrderAssetSummary(order.value) as Record<string, any> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态颜色映射(vant 标签主题)。
|
// 状态颜色映射(vant 标签主题)。
|
||||||
function getStatusTagType(status: string) {
|
function getStatusTagType(status: string) {
|
||||||
if (['completed', 'received'].includes(status)) return 'success'
|
if (['completed', 'received'].includes(status)) return 'success'
|
||||||
@@ -336,11 +323,16 @@ async function copyListingCode() {
|
|||||||
@submit="handleSubmitCheckout"
|
@submit="handleSubmitCheckout"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Checkout Details Display -->
|
<!-- Checkout Details Display(结账中 / 已完成均可查看) -->
|
||||||
<OrderCheckoutSummary
|
<OrderCheckoutSummary
|
||||||
v-if="
|
v-if="
|
||||||
order.checkout &&
|
order.checkout &&
|
||||||
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
|
[
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
'checkout_disputing',
|
||||||
|
'completed',
|
||||||
|
].includes(order.status)
|
||||||
"
|
"
|
||||||
variant="mobile"
|
variant="mobile"
|
||||||
:order="order"
|
:order="order"
|
||||||
@@ -421,32 +413,7 @@ async function copyListingCode() {
|
|||||||
</van-button>
|
</van-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Snapshot Account Details Accordion -->
|
<OrderAccountSnapshot v-if="order" :order="order" variant="mobile" />
|
||||||
<section class="card-section">
|
|
||||||
<van-collapse v-model="activeNames">
|
|
||||||
<van-collapse-item title="查看订单账号快照信息" name="snapshot">
|
|
||||||
<template v-if="readSnapshot()">
|
|
||||||
<van-cell-group :border="false">
|
|
||||||
<van-cell title="区服" :value="order.server_region" />
|
|
||||||
<van-cell title="登录平台" :value="order.login_platform" />
|
|
||||||
<van-cell title="烽火等级" :value="readAssetSummary()?.fire_level || '--'" />
|
|
||||||
<van-cell title="绝密KD" :value="readAssetSummary()?.secret_kd || '--'" />
|
|
||||||
<van-cell
|
|
||||||
title="常用登录地区"
|
|
||||||
:value="readAssetSummary()?.common_regions?.join('、') || '--'"
|
|
||||||
/>
|
|
||||||
<van-cell title="封禁记录" :value="readAssetSummary()?.ban_record || '无'" />
|
|
||||||
<van-cell title="体力等级" :value="readAssetSummary()?.stamina_level || '--'" />
|
|
||||||
<van-cell title="负重等级" :value="readAssetSummary()?.load_level || '--'" />
|
|
||||||
<van-cell title="账号备注" :label="readSnapshot()?.description || '无'" />
|
|
||||||
</van-cell-group>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="no-snapshot-hint">暂无快照数据</div>
|
|
||||||
</template>
|
|
||||||
</van-collapse-item>
|
|
||||||
</van-collapse>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="empty-wrap">
|
<div v-else class="empty-wrap">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ChatDotRound, CopyDocument, Loading, Service } from '@element-plus/icon
|
|||||||
import {
|
import {
|
||||||
amountYuan,
|
amountYuan,
|
||||||
money,
|
money,
|
||||||
|
OrderAccountSnapshot,
|
||||||
OrderCheckoutSummary,
|
OrderCheckoutSummary,
|
||||||
OrderHandoffTimeline,
|
OrderHandoffTimeline,
|
||||||
OrderResourceUsageEditor,
|
OrderResourceUsageEditor,
|
||||||
@@ -467,7 +468,12 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
v-if="
|
v-if="
|
||||||
order &&
|
order &&
|
||||||
order.checkout &&
|
order.checkout &&
|
||||||
['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.status)
|
[
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
'checkout_disputing',
|
||||||
|
'completed',
|
||||||
|
].includes(order.status)
|
||||||
"
|
"
|
||||||
:order="order"
|
:order="order"
|
||||||
:is-owner="isOwner"
|
:is-owner="isOwner"
|
||||||
@@ -569,6 +575,9 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 账号快照放最下方,默认折叠 -->
|
||||||
|
<OrderAccountSnapshot v-if="order" :order="order" variant="desktop" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧悬浮操作区 -->
|
<!-- 右侧悬浮操作区 -->
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { showDialog, showToast } from 'vant'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchSellerListing,
|
||||||
|
offlineListing,
|
||||||
|
submitListingReview,
|
||||||
|
type Listing,
|
||||||
|
} from '@/features/listings'
|
||||||
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
|
import { formatCent, formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { listingReviewStatusLabel, listingStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
import {
|
||||||
|
assetRegions,
|
||||||
|
formatAssetNumber,
|
||||||
|
formatEstimatedRentalDuration,
|
||||||
|
formatHafCoinM,
|
||||||
|
formatListingCode,
|
||||||
|
formatRatio,
|
||||||
|
getCoinWan,
|
||||||
|
getDailyLoss,
|
||||||
|
getListingChips,
|
||||||
|
getListingResources,
|
||||||
|
getListingSellerPrice,
|
||||||
|
getListingSubtitle,
|
||||||
|
getListingTitle,
|
||||||
|
getLoginMethod,
|
||||||
|
getOnlineTimeText,
|
||||||
|
getServerRegion,
|
||||||
|
readAssetNumber,
|
||||||
|
readAssetString,
|
||||||
|
} from '@/shared/utils/listingDisplay'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const acting = ref(false)
|
||||||
|
const listing = ref<Listing | null>(null)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await fetchSellerListing(String(route.params.id))
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '加载失败,仅能查看自己的商品'), icon: 'cross' })
|
||||||
|
listing.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellerPrice = computed(() =>
|
||||||
|
listing.value ? formatMoneyWithSymbol(getListingSellerPrice(listing.value)) : '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
const detailMetrics = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const dailyLoss = getDailyLoss(listing.value)
|
||||||
|
const secretKD = readAssetNumber(listing.value, 'secret_kd')
|
||||||
|
return [
|
||||||
|
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)) },
|
||||||
|
{
|
||||||
|
label: '绝密KD',
|
||||||
|
value: secretKD > 0 ? formatAssetNumber(secretKD) : '--',
|
||||||
|
},
|
||||||
|
{ label: '日损耗', value: dailyLoss ? `${dailyLoss}/天` : '--' },
|
||||||
|
{ label: '价格', value: sellerPrice.value, tone: 'price' },
|
||||||
|
{ label: '押金', value: `¥${formatCent(listing.value.deposit_amount_cent)}` },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailScreenshots = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const groups = listing.value.asset_summary?.screenshot_groups
|
||||||
|
if (typeof groups === 'object' && groups !== null) {
|
||||||
|
const slots = [
|
||||||
|
{ key: 'coin', label: '纯币截图' },
|
||||||
|
{ key: 'gameId', label: '游戏ID截图' },
|
||||||
|
{ key: 'totalAsset', label: '总资产截图' },
|
||||||
|
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
|
||||||
|
{ key: 'skin', label: '皮肤截图' },
|
||||||
|
]
|
||||||
|
return slots.flatMap(slot => {
|
||||||
|
const urls = (groups as Record<string, unknown>)[slot.key]
|
||||||
|
if (!Array.isArray(urls)) return []
|
||||||
|
const valid = urls.filter((u): u is string => typeof u === 'string' && Boolean(u))
|
||||||
|
return valid.map((url, i) => ({
|
||||||
|
label: valid.length > 1 ? `${slot.label}${i + 1}` : slot.label,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
|
||||||
|
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||||
|
label: labels[index] || `账号截图${index + 1}`,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(s => s.url))
|
||||||
|
|
||||||
|
const detailSkinGroups = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const groups = listing.value.asset_summary?.skin_groups
|
||||||
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
melee: '近战皮肤',
|
||||||
|
operator: '干员皮肤',
|
||||||
|
operatorGold: '干员金皮',
|
||||||
|
operatorRed: '干员红皮',
|
||||||
|
weapon: '武器皮肤',
|
||||||
|
}
|
||||||
|
return Object.entries(groups as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
title: titles[key] || key,
|
||||||
|
options: Array.isArray(value)
|
||||||
|
? value.filter((skin): skin is string => typeof skin === 'string')
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.filter(g => g.options.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountRows = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const regions = assetRegions(listing.value)
|
||||||
|
const secretKD = readAssetNumber(listing.value, 'secret_kd')
|
||||||
|
const fireLevel = readAssetNumber(listing.value, 'fire_level')
|
||||||
|
return [
|
||||||
|
{ label: '所属区服', value: getServerRegion(listing.value) || '--' },
|
||||||
|
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' },
|
||||||
|
{ label: '游戏段位', value: listing.value.rank_level || '--' },
|
||||||
|
{ label: '烽火等级', value: fireLevel > 0 ? `${fireLevel}级` : '--' },
|
||||||
|
{ label: '体力等级', value: readAssetString(listing.value, 'stamina_level') || '--' },
|
||||||
|
{ label: '负重等级', value: readAssetString(listing.value, 'load_level') || '--' },
|
||||||
|
{ label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' },
|
||||||
|
{ label: 'M单价', value: formatRatio(listing.value) },
|
||||||
|
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
|
||||||
|
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
|
||||||
|
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
|
||||||
|
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function isTerminal(row: Listing) {
|
||||||
|
return row.status === 'rented' || row.status === 'completed'
|
||||||
|
}
|
||||||
|
function isPendingReview(row: Listing) {
|
||||||
|
return !isTerminal(row) && row.status !== 'offline' && row.review_status === 'pending'
|
||||||
|
}
|
||||||
|
function canEdit(row: Listing) {
|
||||||
|
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
|
||||||
|
}
|
||||||
|
function canSubmit(row: Listing) {
|
||||||
|
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
|
||||||
|
}
|
||||||
|
function canOffline(row: Listing) {
|
||||||
|
return !isTerminal(row) && row.status !== 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode() {
|
||||||
|
if (!listing.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(formatListingCode(listing.value))
|
||||||
|
showToast({ message: '商品编号已复制', icon: 'passed' })
|
||||||
|
} catch {
|
||||||
|
showToast({ message: '复制失败', icon: 'cross' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit() {
|
||||||
|
if (!listing.value) return
|
||||||
|
router.push(`/m/seller/listings/${listing.value.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!listing.value) return
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await submitListingReview(listing.value.id)
|
||||||
|
showToast({
|
||||||
|
message:
|
||||||
|
listing.value.status === 'published' && listing.value.review_status === 'approved'
|
||||||
|
? '已上架'
|
||||||
|
: '已提交审核',
|
||||||
|
icon: 'passed',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '提审失败'), icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOffline() {
|
||||||
|
if (!listing.value) return
|
||||||
|
try {
|
||||||
|
await showDialog({
|
||||||
|
title: '下架确认',
|
||||||
|
message: `确认下架「${listing.value.title}」吗?`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: '确认下架',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await offlineListing(listing.value.id)
|
||||||
|
showToast({ message: '已下架', icon: 'passed' })
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '下架失败'), icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="seller-detail">
|
||||||
|
<header class="page-header">
|
||||||
|
<button type="button" class="icon-btn" @click="router.back()">
|
||||||
|
<van-icon name="arrow-left" :size="20" />
|
||||||
|
</button>
|
||||||
|
<h1>商品详情</h1>
|
||||||
|
<span class="icon-btn" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<van-loading v-if="loading" class="center-loading" size="24px" vertical>加载中...</van-loading>
|
||||||
|
|
||||||
|
<template v-else-if="listing">
|
||||||
|
<div class="status-bar">
|
||||||
|
<button type="button" class="code-chip" @click="copyCode">
|
||||||
|
编号 {{ formatListingCode(listing) }}
|
||||||
|
<van-icon name="description" :size="12" />
|
||||||
|
</button>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">{{ listingStatusLabel(listing.status) }}</span>
|
||||||
|
<span v-if="listing.review_status && listing.review_status !== 'none'" class="tag soft">
|
||||||
|
{{ listingReviewStatusLabel(listing.review_status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="detailScreenshots[0]" class="cover-area">
|
||||||
|
<AuthImage
|
||||||
|
:source="detailScreenshots[0].url"
|
||||||
|
:alt="getListingTitle(listing)"
|
||||||
|
image-class="cover-img"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="detailScreenshotUrls"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>{{ getListingTitle(listing) }}</h2>
|
||||||
|
<p class="sub">{{ getListingSubtitle(listing) }}</p>
|
||||||
|
<div class="tag-row">
|
||||||
|
<van-tag plain type="primary" size="medium">{{ getServerRegion(listing) || '—' }}</van-tag>
|
||||||
|
<van-tag v-if="getLoginMethod(listing)" plain type="primary" size="medium">
|
||||||
|
{{ getLoginMethod(listing) }}
|
||||||
|
</van-tag>
|
||||||
|
<van-tag v-if="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<p v-if="listing.review_reason" class="review-reason">
|
||||||
|
<van-icon name="info-o" :size="13" />
|
||||||
|
{{ listing.review_reason }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metric-row">
|
||||||
|
<div v-for="m in detailMetrics" :key="m.label" class="metric">
|
||||||
|
<span>{{ m.label }}</span>
|
||||||
|
<strong :class="m.tone">{{ m.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>账号资料</h3>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span v-for="chip in getListingChips(listing)" :key="chip.label">
|
||||||
|
{{ chip.label }}:{{ chip.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in accountRows" :key="row.label" class="info-line">
|
||||||
|
<span>{{ row.label }}</span>
|
||||||
|
<em>{{ row.value }}</em>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="getListingResources(listing).length" class="card">
|
||||||
|
<h3>额外消耗品</h3>
|
||||||
|
<div class="resource-grid">
|
||||||
|
<div v-for="r in getListingResources(listing)" :key="r.key" class="resource-pill">
|
||||||
|
<span>{{ r.label }}</span>
|
||||||
|
<strong>{{ r.quantity }}</strong>
|
||||||
|
<small>
|
||||||
|
{{ r.mode || '--' }}
|
||||||
|
<template v-if="r.amount > 0"> · ¥{{ formatMoney(r.amount) }}</template>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detailSkinGroups.length" class="card">
|
||||||
|
<h3>皮肤</h3>
|
||||||
|
<div v-for="g in detailSkinGroups" :key="g.key" class="skin-group">
|
||||||
|
<p>{{ g.title }}</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span v-for="skin in g.options" :key="skin">{{ skin }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="listing.description" class="card">
|
||||||
|
<h3>备注</h3>
|
||||||
|
<p class="desc">{{ listing.description }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detailScreenshots.length" class="card">
|
||||||
|
<h3>账号截图</h3>
|
||||||
|
<div class="shot-grid">
|
||||||
|
<figure v-for="(shot, index) in detailScreenshots" :key="shot.url + index">
|
||||||
|
<AuthImage
|
||||||
|
:source="shot.url"
|
||||||
|
:alt="shot.label"
|
||||||
|
image-class="shot-img"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="detailScreenshotUrls"
|
||||||
|
:preview-initial-index="index"
|
||||||
|
/>
|
||||||
|
<figcaption>{{ shot.label }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>时间信息</h3>
|
||||||
|
<div class="info-line">
|
||||||
|
<span>创建时间</span>
|
||||||
|
<em>{{ formatDateTime(listing.created_at) }}</em>
|
||||||
|
</div>
|
||||||
|
<div v-if="listing.published_at" class="info-line">
|
||||||
|
<span>上架时间</span>
|
||||||
|
<em>{{ formatDateTime(listing.published_at) }}</em>
|
||||||
|
</div>
|
||||||
|
<div class="info-line">
|
||||||
|
<span>更新时间</span>
|
||||||
|
<em>{{ formatDateTime(listing.updated_at) }}</em>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="bottom-spacer" />
|
||||||
|
|
||||||
|
<footer class="action-bar">
|
||||||
|
<van-button v-if="canEdit(listing)" plain round class="btn" @click="goEdit">编辑</van-button>
|
||||||
|
<van-button
|
||||||
|
v-if="canSubmit(listing)"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
class="btn"
|
||||||
|
:loading="acting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
提审
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
v-if="canOffline(listing)"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
class="btn"
|
||||||
|
:loading="acting"
|
||||||
|
@click="handleOffline"
|
||||||
|
>
|
||||||
|
下架
|
||||||
|
</van-button>
|
||||||
|
<van-button plain round class="btn" @click="router.push('/m/seller/listings')">
|
||||||
|
返回列表
|
||||||
|
</van-button>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="empty">
|
||||||
|
<van-empty description="未找到商品,或无权查看" />
|
||||||
|
<van-button round type="primary" @click="router.push('/m/seller/listings')">
|
||||||
|
返回我的商品
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seller-detail {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.center-loading {
|
||||||
|
padding: 60px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px 0;
|
||||||
|
}
|
||||||
|
.code-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tag.soft {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.cover-area {
|
||||||
|
margin: 12px 14px 0;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin: 12px 14px 0;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.sub,
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.review-reason {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.metric-row {
|
||||||
|
margin: 12px 14px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.metric span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.metric strong {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #111827;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.metric strong.price {
|
||||||
|
color: #ff6a00;
|
||||||
|
}
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.chip-row span {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.info-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.info-line:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.info-line span {
|
||||||
|
color: #94a3b8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.info-line em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #111827;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.resource-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.resource-pill {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.resource-pill span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.resource-pill strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.resource-pill small {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.skin-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.skin-group p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.shot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.shot-grid figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.shot-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.shot-grid figcaption {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.action-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.action-bar .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -142,6 +142,10 @@ function goEdit(row: Listing) {
|
|||||||
router.push(`/m/seller/listings/${row.id}/edit`)
|
router.push(`/m/seller/listings/${row.id}/edit`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goDetail(row: Listing) {
|
||||||
|
router.push(`/m/seller/listings/${row.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
function canSubmit(row: Listing) {
|
function canSubmit(row: Listing) {
|
||||||
return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
|
return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
|
||||||
}
|
}
|
||||||
@@ -244,7 +248,7 @@ function isPendingReview(row: Listing) {
|
|||||||
<div v-else v-for="item in displayListings" :key="item.id" class="listing-card">
|
<div v-else v-for="item in displayListings" :key="item.id" class="listing-card">
|
||||||
<!-- 卡片头:编号 + 状态 -->
|
<!-- 卡片头:编号 + 状态 -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<button class="listing-code-chip" type="button" @click="copyListingCode(item)">
|
<button class="listing-code-chip" type="button" @click.stop="copyListingCode(item)">
|
||||||
编号 {{ formatListingCode(item) }}
|
编号 {{ formatListingCode(item) }}
|
||||||
<van-icon name="description" :size="12" />
|
<van-icon name="description" :size="12" />
|
||||||
</button>
|
</button>
|
||||||
@@ -262,35 +266,47 @@ function isPendingReview(row: Listing) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 卡片体 -->
|
<!-- 卡片体:点击查看完整发布信息(仅本人) -->
|
||||||
<h3 class="listing-title">{{ item.title }}</h3>
|
<button type="button" class="card-body-btn" @click="goDetail(item)">
|
||||||
|
<h3 class="listing-title">{{ item.title }}</h3>
|
||||||
|
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="meta-label">区服</span>
|
<span class="meta-label">区服</span>
|
||||||
<span class="meta-val">{{ item.server_region || '-' }}</span>
|
<span class="meta-val">{{ item.server_region || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">哈夫币</span>
|
||||||
|
<span class="meta-val">{{ coinText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">价格</span>
|
||||||
|
<span class="meta-val price">{{ listingPrice(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">押金</span>
|
||||||
|
<span class="meta-val">¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-item">
|
|
||||||
<span class="meta-label">哈夫币</span>
|
|
||||||
<span class="meta-val">{{ coinText(item) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="meta-label">价格</span>
|
|
||||||
<span class="meta-val price">{{ listingPrice(item) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="meta-label">押金</span>
|
|
||||||
<span class="meta-val">¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="item.review_reason" class="review-reason">
|
<p v-if="item.review_reason" class="review-reason">
|
||||||
<van-icon name="info-o" :size="13" />
|
<van-icon name="info-o" :size="13" />
|
||||||
{{ item.review_reason }}
|
{{ item.review_reason }}
|
||||||
</p>
|
</p>
|
||||||
|
<span class="detail-hint">查看完整信息 ›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- 卡片底:操作 -->
|
<!-- 卡片底:操作 -->
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
class="action-btn"
|
||||||
|
@click="goDetail(item)"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</van-button>
|
||||||
<van-button
|
<van-button
|
||||||
v-if="canEdit(item)"
|
v-if="canEdit(item)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -509,6 +525,18 @@ function isPendingReview(row: Listing) {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-body-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-title {
|
.listing-title {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -517,6 +545,14 @@ function isPendingReview(row: Listing) {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-hint {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-grid {
|
.meta-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowLeft, CopyDocument, Edit } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchSellerListing,
|
||||||
|
offlineListing,
|
||||||
|
submitListingReview,
|
||||||
|
type Listing,
|
||||||
|
} from '@/features/listings'
|
||||||
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
|
import { formatCent, formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { listingReviewStatusLabel, listingStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
import {
|
||||||
|
assetRegions,
|
||||||
|
formatAssetNumber,
|
||||||
|
formatEstimatedRentalDuration,
|
||||||
|
formatHafCoinM,
|
||||||
|
formatListingCode,
|
||||||
|
formatRatio,
|
||||||
|
getCoinWan,
|
||||||
|
getDailyLoss,
|
||||||
|
getListingChips,
|
||||||
|
getListingResources,
|
||||||
|
getListingSellerPrice,
|
||||||
|
getListingSubtitle,
|
||||||
|
getListingTitle,
|
||||||
|
getLoginMethod,
|
||||||
|
getOnlineTimeText,
|
||||||
|
getServerRegion,
|
||||||
|
readAssetNumber,
|
||||||
|
readAssetString,
|
||||||
|
} from '@/shared/utils/listingDisplay'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const acting = ref(false)
|
||||||
|
const listing = ref<Listing | null>(null)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await fetchSellerListing(String(route.params.id))
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '加载失败,仅能查看自己的商品'))
|
||||||
|
listing.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellerPrice = computed(() =>
|
||||||
|
listing.value ? formatMoneyWithSymbol(getListingSellerPrice(listing.value)) : '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
const detailScreenshots = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const groups = listing.value.asset_summary?.screenshot_groups
|
||||||
|
if (typeof groups === 'object' && groups !== null) {
|
||||||
|
const slots = [
|
||||||
|
{ key: 'coin', label: '纯币截图' },
|
||||||
|
{ key: 'gameId', label: '游戏ID截图' },
|
||||||
|
{ key: 'totalAsset', label: '总资产截图' },
|
||||||
|
{ key: 'tencentSecurity', label: '腾讯安全中心截图' },
|
||||||
|
{ key: 'skin', label: '皮肤截图' },
|
||||||
|
]
|
||||||
|
return slots.flatMap(slot => {
|
||||||
|
const urls = (groups as Record<string, unknown>)[slot.key]
|
||||||
|
if (!Array.isArray(urls)) return []
|
||||||
|
const valid = urls.filter((u): u is string => typeof u === 'string' && Boolean(u))
|
||||||
|
return valid.map((url, i) => ({
|
||||||
|
label: valid.length > 1 ? `${slot.label}${i + 1}` : slot.label,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
|
||||||
|
return (listing.value.screenshot_urls || []).map((url, index) => ({
|
||||||
|
label: labels[index] || `账号截图${index + 1}`,
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(s => s.url))
|
||||||
|
|
||||||
|
const detailSkinGroups = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const groups = listing.value.asset_summary?.skin_groups
|
||||||
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
melee: '近战皮肤',
|
||||||
|
operator: '干员皮肤',
|
||||||
|
operatorGold: '干员金皮',
|
||||||
|
operatorRed: '干员红皮',
|
||||||
|
weapon: '武器皮肤',
|
||||||
|
}
|
||||||
|
return Object.entries(groups as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
title: titles[key] || key,
|
||||||
|
options: Array.isArray(value)
|
||||||
|
? value.filter((skin): skin is string => typeof skin === 'string')
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.filter(g => g.options.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountRows = computed(() => {
|
||||||
|
if (!listing.value) return []
|
||||||
|
const regions = assetRegions(listing.value)
|
||||||
|
const secretKD = readAssetNumber(listing.value, 'secret_kd')
|
||||||
|
const fireLevel = readAssetNumber(listing.value, 'fire_level')
|
||||||
|
return [
|
||||||
|
{ label: '所属区服', value: getServerRegion(listing.value) || '--' },
|
||||||
|
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' },
|
||||||
|
{ label: '游戏段位', value: listing.value.rank_level || '--' },
|
||||||
|
{ label: '烽火等级', value: fireLevel > 0 ? `${fireLevel}级` : '--' },
|
||||||
|
{ label: '体力等级', value: readAssetString(listing.value, 'stamina_level') || '--' },
|
||||||
|
{ label: '负重等级', value: readAssetString(listing.value, 'load_level') || '--' },
|
||||||
|
{ label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' },
|
||||||
|
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)) },
|
||||||
|
{ label: '日损耗', value: getDailyLoss(listing.value) ? `${getDailyLoss(listing.value)}/天` : '--' },
|
||||||
|
{ label: 'M单价', value: formatRatio(listing.value) },
|
||||||
|
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
|
||||||
|
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
|
||||||
|
{ label: '常用登录地', value: regions.length ? regions.join('、') : '--' },
|
||||||
|
{ label: '封禁记录', value: readAssetString(listing.value, 'ban_record') || '无' },
|
||||||
|
{ label: '卖家到手价', value: sellerPrice.value },
|
||||||
|
{ label: '押金', value: `¥${formatCent(listing.value.deposit_amount_cent)}` },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function isTerminal(row: Listing) {
|
||||||
|
return row.status === 'rented' || row.status === 'completed'
|
||||||
|
}
|
||||||
|
function isPendingReview(row: Listing) {
|
||||||
|
return !isTerminal(row) && row.status !== 'offline' && row.review_status === 'pending'
|
||||||
|
}
|
||||||
|
function canEdit(row: Listing) {
|
||||||
|
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
|
||||||
|
}
|
||||||
|
function canSubmit(row: Listing) {
|
||||||
|
return !isTerminal(row) && !isPendingReview(row) && row.status !== 'published'
|
||||||
|
}
|
||||||
|
function canOffline(row: Listing) {
|
||||||
|
return !isTerminal(row) && row.status !== 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode() {
|
||||||
|
if (!listing.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(formatListingCode(listing.value))
|
||||||
|
ElMessage.success('商品编号已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!listing.value) return
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await submitListingReview(listing.value.id)
|
||||||
|
ElMessage.success(
|
||||||
|
listing.value.status === 'published' && listing.value.review_status === 'approved'
|
||||||
|
? '已上架'
|
||||||
|
: '已提交审核'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '提审失败'))
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOffline() {
|
||||||
|
if (!listing.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认下架「${listing.value.title}」吗?`, '下架确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认下架',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acting.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await offlineListing(listing.value.id)
|
||||||
|
ElMessage.success('已下架')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '下架失败'))
|
||||||
|
} finally {
|
||||||
|
acting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="seller-detail-page">
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<el-button text :icon="ArrowLeft" @click="router.push('/seller/listings')">返回列表</el-button>
|
||||||
|
<h1>我的商品详情</h1>
|
||||||
|
<p>仅本人可查看完整发布信息;他人无法通过此入口访问。</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="listing" class="head-actions">
|
||||||
|
<el-button :icon="CopyDocument" @click="copyCode">复制编号</el-button>
|
||||||
|
<el-button v-if="canEdit(listing)" :icon="Edit" @click="router.push(`/seller/listings/${listing.id}/edit`)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="canSubmit(listing)" type="primary" plain :loading="acting" @click="handleSubmit">
|
||||||
|
提审
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="canOffline(listing)" type="danger" plain :loading="acting" @click="handleOffline">
|
||||||
|
下架
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-empty v-if="!loading && !listing" description="未找到商品,或无权查看" />
|
||||||
|
|
||||||
|
<template v-else-if="listing">
|
||||||
|
<div class="hero card">
|
||||||
|
<div class="hero-main">
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">编号 {{ formatListingCode(listing) }}</span>
|
||||||
|
<el-tag size="small">{{ listingStatusLabel(listing.status) }}</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="listing.review_status && listing.review_status !== 'none'"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
>
|
||||||
|
{{ listingReviewStatusLabel(listing.review_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<h2>{{ getListingTitle(listing) }}</h2>
|
||||||
|
<p>{{ getListingSubtitle(listing) }}</p>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="chip in getListingChips(listing)" :key="chip.label">
|
||||||
|
{{ chip.label }}: {{ chip.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="listing.review_reason" class="reason">审核备注:{{ listing.review_reason }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-price">
|
||||||
|
<small>卖家到手价</small>
|
||||||
|
<strong>{{ sellerPrice }}</strong>
|
||||||
|
<em>押金 ¥{{ formatCent(listing.deposit_amount_cent) }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card">
|
||||||
|
<h3>账号资料</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<template v-for="row in accountRows" :key="row.label">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>{{ row.value }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>时间信息</h3>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>创建时间</dt>
|
||||||
|
<dd>{{ formatDateTime(listing.created_at) }}</dd>
|
||||||
|
<dt>上架时间</dt>
|
||||||
|
<dd>{{ listing.published_at ? formatDateTime(listing.published_at) : '—' }}</dd>
|
||||||
|
<dt>更新时间</dt>
|
||||||
|
<dd>{{ formatDateTime(listing.updated_at) }}</dd>
|
||||||
|
</dl>
|
||||||
|
<h3 class="sub-title">备注</h3>
|
||||||
|
<p class="desc">{{ listing.description || '无' }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="getListingResources(listing).length" class="card">
|
||||||
|
<h3>额外消耗品</h3>
|
||||||
|
<div class="resource-grid">
|
||||||
|
<div v-for="r in getListingResources(listing)" :key="r.key" class="resource">
|
||||||
|
<span>{{ r.label }}</span>
|
||||||
|
<strong>{{ r.quantity }}</strong>
|
||||||
|
<small>
|
||||||
|
{{ r.mode || '--' }}
|
||||||
|
<template v-if="r.amount > 0"> · ¥{{ formatMoney(r.amount) }}</template>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detailSkinGroups.length" class="card">
|
||||||
|
<h3>皮肤</h3>
|
||||||
|
<div v-for="g in detailSkinGroups" :key="g.key" class="skin-group">
|
||||||
|
<p>{{ g.title }}</p>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="skin in g.options" :key="skin">{{ skin }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detailScreenshots.length" class="card">
|
||||||
|
<h3>账号截图</h3>
|
||||||
|
<div class="shots">
|
||||||
|
<figure v-for="(shot, index) in detailScreenshots" :key="shot.url + index">
|
||||||
|
<AuthImage
|
||||||
|
:source="shot.url"
|
||||||
|
:alt="shot.label"
|
||||||
|
image-class="shot-img"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="detailScreenshotUrls"
|
||||||
|
:preview-initial-index="index"
|
||||||
|
/>
|
||||||
|
<figcaption>{{ shot.label }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seller-detail-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.page-head h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.page-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.hero-main h2 {
|
||||||
|
margin: 8px 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.hero-main p {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.code-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.chips span {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
margin-top: 12px !important;
|
||||||
|
color: #c2410c !important;
|
||||||
|
}
|
||||||
|
.hero-price {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff7ed;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.hero-price small,
|
||||||
|
.hero-price em {
|
||||||
|
display: block;
|
||||||
|
color: #9a3412;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hero-price strong {
|
||||||
|
display: block;
|
||||||
|
margin: 6px 0;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.sub-title {
|
||||||
|
margin-top: 18px !important;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kv dt {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.resource-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.resource {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.resource span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.resource strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.resource small {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.skin-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.skin-group p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.shots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.shots figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.shot-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.shots figcaption {
|
||||||
|
margin-top: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero,
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CopyDocument, Edit, Plus, Refresh } from '@element-plus/icons-vue'
|
import { CopyDocument, Edit, Plus, Refresh, View } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchSellerListings,
|
fetchSellerListings,
|
||||||
@@ -20,6 +20,7 @@ const offliningID = ref<number | null>(null)
|
|||||||
const listings = ref<Listing[]>([])
|
const listings = ref<Listing[]>([])
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const displayListings = computed(() => {
|
const displayListings = computed(() => {
|
||||||
if (statusFilter.value === 'all') return listings.value
|
if (statusFilter.value === 'all') return listings.value
|
||||||
@@ -146,6 +147,16 @@ function editPath(row: Listing) {
|
|||||||
: `/seller/listings/${row.id}/edit`
|
: `/seller/listings/${row.id}/edit`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detailPath(row: Listing) {
|
||||||
|
return route.path.startsWith('/m/')
|
||||||
|
? `/m/seller/listings/${row.id}`
|
||||||
|
: `/seller/listings/${row.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(row: Listing) {
|
||||||
|
router.push(detailPath(row))
|
||||||
|
}
|
||||||
|
|
||||||
function statusTone(status: string) {
|
function statusTone(status: string) {
|
||||||
const tones: Record<string, string> = {
|
const tones: Record<string, string> = {
|
||||||
published: 'success',
|
published: 'success',
|
||||||
@@ -220,7 +231,7 @@ function isPendingReview(row: Listing) {
|
|||||||
|
|
||||||
<article v-for="item in displayListings" :key="item.id" class="listing-card">
|
<article v-for="item in displayListings" :key="item.id" class="listing-card">
|
||||||
<div class="listing-card-head">
|
<div class="listing-card-head">
|
||||||
<button class="listing-code-chip" type="button" @click="copyListingCode(item)">
|
<button class="listing-code-chip" type="button" @click.stop="copyListingCode(item)">
|
||||||
编号 {{ formatListingCode(item) }}
|
编号 {{ formatListingCode(item) }}
|
||||||
<el-icon><CopyDocument /></el-icon>
|
<el-icon><CopyDocument /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
@@ -238,30 +249,34 @@ function isPendingReview(row: Listing) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="listing-title">{{ item.title }}</h3>
|
<button type="button" class="listing-body-btn" @click="goDetail(item)">
|
||||||
|
<h3 class="listing-title">{{ item.title }}</h3>
|
||||||
|
|
||||||
<div class="listing-meta-grid">
|
<div class="listing-meta-grid">
|
||||||
<div>
|
<div>
|
||||||
<span>区服</span>
|
<span>区服</span>
|
||||||
<strong>{{ item.server_region || '-' }}</strong>
|
<strong>{{ item.server_region || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>哈夫币</span>
|
||||||
|
<strong>{{ coinText(item) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>价格</span>
|
||||||
|
<strong class="price-text">{{ listingPrice(item) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>押金</span>
|
||||||
|
<strong>¥{{ formatCent(item.deposit_amount_cent) }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span>哈夫币</span>
|
|
||||||
<strong>{{ coinText(item) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>价格</span>
|
|
||||||
<strong class="price-text">{{ listingPrice(item) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>押金</span>
|
|
||||||
<strong>¥{{ formatCent(item.deposit_amount_cent) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="item.review_reason" class="review-reason">{{ item.review_reason }}</p>
|
<p v-if="item.review_reason" class="review-reason">{{ item.review_reason }}</p>
|
||||||
|
<span class="detail-hint">查看完整发布信息 ›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="listing-actions">
|
<div class="listing-actions">
|
||||||
|
<el-button :icon="View" @click="goDetail(item)">详情</el-button>
|
||||||
<RouterLink v-if="canEdit(item)" :to="editPath(item)">
|
<RouterLink v-if="canEdit(item)" :to="editPath(item)">
|
||||||
<el-button :icon="Edit">编辑</el-button>
|
<el-button :icon="Edit">编辑</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -423,6 +438,17 @@ function isPendingReview(row: Listing) {
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-body-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-title {
|
.listing-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -435,6 +461,14 @@ function isPendingReview(row: Listing) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-hint {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #ff6a00;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.listing-tags {
|
.listing-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export function getMobilePath(path: string): string {
|
|||||||
if (path.startsWith('/seller/listings/') && path.endsWith('/edit')) {
|
if (path.startsWith('/seller/listings/') && path.endsWith('/edit')) {
|
||||||
return `/m${path}`
|
return `/m${path}`
|
||||||
}
|
}
|
||||||
|
if (path === '/seller/listings' || path.startsWith('/seller/listings/')) {
|
||||||
|
return `/m${path}`
|
||||||
|
}
|
||||||
if (path === '/messages' || path.startsWith('/messages/')) {
|
if (path === '/messages' || path.startsWith('/messages/')) {
|
||||||
return '/m/messages'
|
return '/m/messages'
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,7 @@ export function getPcPath(path: string): string {
|
|||||||
if (subPath === '/wallet/withdrawal') return '/wallet/withdrawal'
|
if (subPath === '/wallet/withdrawal') return '/wallet/withdrawal'
|
||||||
if (subPath === '/seller/listings/create') return '/seller/listings/create'
|
if (subPath === '/seller/listings/create') return '/seller/listings/create'
|
||||||
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
|
if (subPath.startsWith('/seller/listings/') && subPath.endsWith('/edit')) return subPath
|
||||||
|
if (subPath === '/seller/listings' || subPath.startsWith('/seller/listings/')) return subPath
|
||||||
|
|
||||||
if (subPath === '/messages') return '/messages'
|
if (subPath === '/messages') return '/messages'
|
||||||
if (subPath.startsWith('/chats/')) return '/messages/' + subPath.substring(7)
|
if (subPath.startsWith('/chats/')) return '/messages/' + subPath.substring(7)
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'),
|
component: () => import('@/features/seller/views/MobileSellerListingCreateView.vue'),
|
||||||
meta: { layout: 'blank', requiresAuth: true, requiresRealname: true },
|
meta: { layout: 'blank', requiresAuth: true, requiresRealname: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/m/seller/listings/:id',
|
||||||
|
name: 'mobile-seller-listing-detail',
|
||||||
|
component: () => import('@/features/seller/views/MobileSellerListingDetailView.vue'),
|
||||||
|
meta: { layout: 'blank', requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/m/seller/listings',
|
path: '/m/seller/listings',
|
||||||
name: 'mobile-seller-listings',
|
name: 'mobile-seller-listings',
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export const sellerRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/seller/views/SellerListingCreateView.vue'),
|
component: () => import('@/features/seller/views/SellerListingCreateView.vue'),
|
||||||
meta: { requiresAuth: true, requiresRealname: true },
|
meta: { requiresAuth: true, requiresRealname: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/seller/listings/:id',
|
||||||
|
name: 'seller-listing-detail',
|
||||||
|
component: () => import('@/features/seller/views/SellerListingDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/seller/handoffs',
|
path: '/seller/handoffs',
|
||||||
name: 'seller-handoffs',
|
name: 'seller-handoffs',
|
||||||
|
|||||||
Reference in New Issue
Block a user