Files
hfb_sys/frontend/src/views/mobile/MobileListingDetailView.vue
T

747 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { fetchListing, type Listing } from "@/api/listings";
import { createOrder } from "@/api/orders";
import { useSessionStore } from "@/stores/session";
import {
assetRegions,
formatHafCoinM,
formatRatio,
getCoinWan,
getDailyLoss,
getListingChips,
getListingDisplayPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getRentDays,
getServerRegion,
readAssetNumber,
readAssetString,
} from "./listingDisplay";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
const rentHours = ref(1);
const listing = ref<Listing | null>(null);
onMounted(async () => {
loading.value = true;
try {
listing.value = await fetchListing(String(route.params.id));
rentHours.value = Math.max(listing.value.min_rent_hours || 1, 1);
} catch {
showToast({ message: "加载失败", icon: "warning-o" });
} finally {
loading.value = false;
}
});
/* 预计租金 */
const totalRent = computed(() => {
if (!listing.value) return "0.00";
return (listing.value.price_hourly * rentHours.value).toFixed(2);
});
const detailMetrics = computed(() => {
if (!listing.value) return [];
const totalAssetWan = readAssetNumber(listing.value, "total_asset_wan");
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{
label: "总资产",
value: totalAssetWan > 0 ? formatHafCoinM(totalAssetWan) : "--",
tone: "coin",
},
{ label: "价格", value: ${getListingDisplayPrice(listing.value)}`, tone: "price" },
{ label: "押金", value: ${listing.value.deposit_amount}`, tone: "" },
];
});
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
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: "干员皮肤",
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((group) => group.options.length);
});
/* 下单 */
async function handleCreateOrder() {
if (!listing.value) return;
if (!session.token) {
showDialog({
title: "请先登录",
message: "下单需要登录账号,是否前往登录?",
confirmButtonText: "去登录",
cancelButtonText: "取消",
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/login", query: { redirect: route.fullPath } });
});
return;
}
if (session.realnameStatus !== "verified") {
try {
await session.loadMe();
} catch {
// 401 会由全局拦截器处理。
}
}
if (session.realnameStatus !== "verified") {
showDialog({
title: "请先实名认证",
message: "租号下单前需要完成实名认证。",
confirmButtonText: "去认证",
cancelButtonText: "取消",
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/realname", query: { redirect: route.fullPath } });
});
return;
}
ordering.value = true;
try {
const order = await createOrder(listing.value.id, rentHours.value);
showToast({ message: "订单已创建,账号已锁定", icon: "passed" });
await router.push(`/m/orders`);
} catch (error) {
showToast({ message: readError(error, "下单失败"), icon: "cross" });
} finally {
ordering.value = false;
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === "object" && error && "response" in error) {
const response = (error as { response?: { data?: { message?: string } } })
.response;
return response?.data?.message || fallback;
}
return fallback;
}
/** 判断当前底部导航是否激活 */
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
return route.path.startsWith(path);
}
</script>
<template>
<main class="mobile-detail">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>账号详情</h1>
<span class="header-spacer"></span>
</header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
加载中...
</van-loading>
<template v-else-if="listing">
<!-- 防骗提示 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
</div>
<!-- 封面图 -->
<div class="cover-area">
<img
v-if="detailScreenshots[0]?.url"
:src="detailScreenshots[0].url"
:alt="getListingTitle(listing)"
class="cover-img"
/>
<div v-else class="cover-placeholder">
<van-icon name="photo-o" :size="40" color="#ccc" />
<span>暂无截图</span>
</div>
<!-- 截图指示器 -->
<div
v-if="detailScreenshots.length > 1"
class="cover-count"
>
{{ detailScreenshots.length }}
</div>
</div>
<!-- 标题信息 -->
<div class="info-card">
<div class="info-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>
<van-tag
v-if="getDailyLoss(listing)"
color="#fff7ed"
text-color="#ea580c"
size="medium"
>
损耗 {{ getDailyLoss(listing) }}
</van-tag>
</div>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
</div>
<!-- 资产指标 -->
<div class="metric-row">
<div v-for="metric in detailMetrics" :key="metric.label" class="metric-item">
<span class="metric-label">{{ metric.label }}</span>
<strong class="metric-value" :class="metric.tone">{{ metric.value }}</strong>
</div>
</div>
<!-- 基础信息 -->
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span
v-for="chip in getListingChips(listing)"
:key="chip.label"
>
{{ chip.label }}:{{ chip.value }}
</span>
</div>
<div class="info-line">
<span class="info-label">比例</span>
<span class="info-text">{{ formatRatio(listing) }}</span>
</div>
<div class="info-line">
<span class="info-label">租期</span>
<span class="info-text">{{ getRentDays(listing) }}</span>
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</span>
</div>
<div v-if="readAssetString(listing, 'ban_record')" class="info-line">
<span class="info-label">封禁记录</span>
<span class="info-text">{{ readAssetString(listing, "ban_record") }}</span>
</div>
</div>
<div v-if="getListingResources(listing).length" class="info-card">
<h3 class="card-subtitle">资源道具</h3>
<div class="resource-grid">
<div
v-for="resource in getListingResources(listing)"
:key="resource.key"
class="resource-pill"
>
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>{{ resource.mode }}</em>
</div>
</div>
</div>
<div v-if="detailSkinGroups.length" class="info-card">
<h3 class="card-subtitle">皮肤</h3>
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-detail-group">
<p>{{ group.title }}</p>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
<div v-if="listing.description" class="info-card">
<h3 class="card-subtitle">备注</h3>
<p class="detail-desc">{{ listing.description }}</p>
</div>
<!-- 截图列表 -->
<div v-if="detailScreenshots.length" class="info-card">
<h3 class="card-subtitle">账号截图</h3>
<div class="screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="screenshot-item">
<img :src="shot.url" class="screenshot-thumb" />
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</div>
<!-- 留白给底部下单栏 -->
<div class="bottom-spacer"></div>
<!-- 底部下单栏固定 -->
<div class="order-bar">
<div class="order-bar-left">
<div class="rent-control">
<button
class="rent-btn"
:disabled="rentHours <= listing.min_rent_hours"
@click="rentHours--"
>
</button>
<span class="rent-value">{{ rentHours }}小时</span>
<button
class="rent-btn"
:disabled="rentHours >= listing.max_rent_hours"
@click="rentHours++"
>
+
</button>
</div>
<div class="order-price">
<span class="price-label">租金</span>
<span class="price-amount">¥{{ totalRent }}</span>
</div>
</div>
<van-button
type="primary"
round
class="order-btn"
:loading="ordering"
loading-text="下单中..."
@click="handleCreateOrder"
>
立即下单
</van-button>
</div>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<van-icon name="info-o" :size="48" color="#ccc" />
<p>未找到该账号信息</p>
</div>
</main>
</template>
<style scoped>
.mobile-detail {
min-height: 100dvh;
background: #f5f7fa;
padding-bottom: 80px;
}
/* ========== 顶部导航 ========== */
.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;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #333;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
.center-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
/* ========== 防骗提示 ========== */
.fraud-tip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #fff8e1;
font-size: 11px;
color: #e65100;
line-height: 1.4;
}
/* ========== 封面图 ========== */
.cover-area {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #e8e8e8;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 12px;
}
.cover-count {
position: absolute;
right: 10px;
bottom: 10px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
}
/* ========== 信息卡片 ========== */
.info-card {
margin: 10px 12px;
padding: 14px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.info-tag-row {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.detail-title {
margin: 0 0 6px;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.35;
}
.detail-desc {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
/* ========== 资产指标 ========== */
.metric-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 12px;
}
.metric-item {
background: #fff;
border-radius: 10px;
padding: 10px 6px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.metric-label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.metric-value {
display: block;
overflow: hidden;
font-size: 15px;
color: #1a1a1a;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-value.coin {
color: #1477ff;
}
.metric-value.price {
color: #ff5f00;
}
/* ========== 租期信息 ========== */
.info-line {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
}
.info-line:last-child {
border-bottom: none;
}
.info-label {
color: #999;
}
.info-text {
color: #333;
font-weight: 500;
}
/* ========== 截图列表 ========== */
.card-subtitle {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
}
.detail-chip-row {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 10px;
}
.detail-chip-row span {
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid #ff8a1f;
border-radius: 5px;
color: #ff7900;
padding: 0 7px;
font-size: 12px;
font-weight: 800;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.resource-pill {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
border-radius: 8px;
background: #f7f9fc;
padding: 10px;
}
.resource-pill span {
min-width: 0;
color: #5f6b7a;
font-size: 12px;
font-weight: 700;
}
.resource-pill strong {
color: #17233d;
font-size: 14px;
}
.resource-pill em {
grid-column: 1 / -1;
color: #ff7900;
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.skin-detail-group + .skin-detail-group {
margin-top: 12px;
}
.skin-detail-group p {
margin: 0 0 8px;
color: #5f6b7a;
font-size: 12px;
font-weight: 900;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.screenshot-item {
margin: 0;
}
.screenshot-thumb {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
}
.screenshot-item figcaption {
margin-top: 4px;
color: #6b7280;
font-size: 11px;
font-weight: 700;
text-align: center;
}
/* ========== 底部留白 ========== */
.bottom-spacer {
height: 20px;
}
/* ========== 底部下单栏 ========== */
.order-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
.order-bar-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.rent-control {
display: flex;
align-items: center;
gap: 6px;
}
.rent-btn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid #ddd;
border-radius: 6px;
background: #f5f7fa;
color: #333;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.rent-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.rent-value {
font-size: 14px;
font-weight: 700;
min-width: 48px;
text-align: center;
}
.order-price {
display: flex;
align-items: baseline;
gap: 4px;
}
.price-label {
font-size: 11px;
color: #999;
}
.price-amount {
font-size: 18px;
font-weight: 900;
color: #ff5f00;
}
.order-btn {
flex-shrink: 0;
padding: 0 20px;
height: 40px;
font-size: 14px;
font-weight: 700;
background: #ff6a00 !important;
border: none !important;
}
/* ========== 空状态 ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 80px 0;
color: #999;
font-size: 14px;
}
</style>