Files
hfb_sys/frontend/src/features/listings/views/ListingDetailView.vue
T
yml2213andClaude Opus 4.7 405abfa4f7 feat: P2阶段完成 - listings和auth模块迁移
## P2.1: 商品浏览模块(listings)✅

### 完整迁移(23个文件)
- API: listings.ts, listingOptions.ts, homeConfig.ts
- Views: 5个页面(HomeView, ListingsView, ListingDetailView + 移动端2个)
- Composables: 3个(useHomeFilters, useFilterOptions, useListingQuery)
- Components: 9个(ListingCard, 各种过滤器组件)
- Tests: 2个测试文件

**功能:**
- 首页浏览、商品列表、详情查看
- 高级筛选(服务器、等级、哈夫币、皮肤等)
- 排序和分区功能
- 横幅、公告、统计展示

**技术改进:**
- 更新所有导入路径到 @/shared/
- 建立清晰的模块导出

---

## P2.2: 用户认证模块(auth)✅

### 完整迁移(12个文件)
- API: auth.ts, realname.ts, notifications.ts
- Views: 8个页面(登录、注册、个人资料、实名认证、通知 + 移动端)
- 模块导出

**功能:**
- 用户注册、登录、Token管理
- 实名认证、风险检查
- 个人资料编辑、头像上传
- 消息/通知中心

**技术改进:**
- 统一 API 导入路径
- 类型安全增强

---

## 里程碑

🎉 **P2 阶段完成!**

**累计完成:** 6个模块
- ✅ P0: shared(基础设施)- 22个文件
- ✅ P1: wallet, chats, orders - 24个文件
- ✅ P2: listings, auth - 35个文件

**总计:** 81个文件已迁移

**剩余:** P3阶段(seller, disputes, admin)+ 清理工作

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:56:42 +08:00

750 lines
18 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 { ElMessage } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { fetchListing, type Listing } from "@/api/listings";
import { createOrder } from "@/api/orders";
import { useSessionStore } from "@/stores/session";
import {
assetRegions,
formatEstimatedRentalDuration,
formatHafCoinM,
formatRatio,
getCoinWan,
getDailyLoss,
getListingConsumablePrice,
getListingChips,
getListingDisplayPrice,
getListingRentPrice,
getListingResources,
getListingSubtitle,
getListingTitle,
getOnlineTimeText,
getLoginMethod,
getServerRegion,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const ordering = ref(false);
const listing = ref<Listing | null>(null);
onMounted(async () => {
loading.value = true;
try {
listing.value = await fetchListing(String(route.params.id));
} finally {
loading.value = false;
}
});
const orderTotal = computed(() => {
if (!listing.value) return "0";
return `${Math.round(getListingDisplayPrice(listing.value))}`;
});
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
return {
rent: 0,
consumable: 0,
total: 0,
};
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
total: Math.round(getListingDisplayPrice(listing.value)),
};
});
const coverURL = computed(() => {
if (!listing.value) return "";
return listing.value.cover_url || listing.value.screenshot_urls?.[0] || "";
});
const detailMetrics = computed(() => {
if (!listing.value) return [];
const dailyLoss = getDailyLoss(listing.value);
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{
label: "日损耗",
value: dailyLoss ? `${dailyLoss}/天` : "--",
tone: "coin",
},
{ label: "价格", value: `¥${orderTotal.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: "干员皮肤",
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((group) => group.options.length);
});
const accountRows = computed(() => {
if (!listing.value) return [];
const regions = assetRegions(listing.value);
return [
{ label: "所属区服", value: getServerRegion(listing.value) || "--" },
{ label: "上号方式", value: getLoginMethod(listing.value) || "--" },
{ label: "游戏段位", value: listing.value.rank_level || "--" },
{ 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") || "无" },
];
});
async function handleCreateOrder() {
if (!listing.value) return;
if (!session.token) {
await router.push({ path: "/login", query: { redirect: route.fullPath } });
return;
}
ordering.value = true;
try {
const order = await createOrder(listing.value.id);
ElMessage.success("订单已创建,请完成支付");
await router.push(`/orders/${order.id}`);
} catch (error) {
ElMessage.error(readError(error, "下单失败"));
} 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 listingPrice(item: Listing) {
return `${Math.round(getListingDisplayPrice(item))}`;
}
</script>
<template>
<section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact">
<span
>防骗提示:下单后请按平台交接流程确认收号与归还,不要私下交易。</span
>
</div>
<div v-if="listing" class="pc-detail-layout">
<section class="pc-detail-main">
<div class="detail-hero-card">
<img
v-if="coverURL"
:src="coverURL"
:alt="getListingTitle(listing)"
/>
<span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay">
<div class="detail-tags">
<span>{{ getServerRegion(listing) }}</span>
<span v-if="getLoginMethod(listing)">{{ getLoginMethod(listing) }}</span>
<span v-if="listing.rank_level">{{ listing.rank_level }}</span>
</div>
<h1>{{ getListingTitle(listing) }}</h1>
<p>{{ getListingSubtitle(listing) }}</p>
</div>
</div>
<div class="detail-body">
<div class="detail-summary-row">
<div v-for="metric in detailMetrics" :key="metric.label" class="detail-metric" :class="`is-${metric.tone}`">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</div>
</div>
<section class="detail-section">
<div class="detail-section-head">
<h2>账号资料</h2>
<span>{{ listing.game_name || "三角洲行动" }}</span>
</div>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
<dl class="detail-info-grid">
<div v-for="row in accountRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</section>
<section v-if="getListingResources(listing).length" class="detail-section">
<div class="detail-section-head">
<h2>额外消耗品</h2>
<span>{{ getListingResources(listing).length }} 项</span>
</div>
<div class="detail-resource-grid">
<div v-for="resource in getListingResources(listing)" :key="resource.key" class="detail-resource-card">
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || "--" }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || "¥0" }}</small>
<small v-else>无额外收费</small>
</em>
</div>
</div>
</section>
<section v-if="detailSkinGroups.length" class="detail-section">
<div class="detail-section-head">
<h2>皮肤清单</h2>
<span>按类型展示</span>
</div>
<div class="skin-groups">
<div v-for="group in detailSkinGroups" :key="group.key" class="skin-group">
<h3>{{ group.title }}</h3>
<div class="detail-chip-row">
<span v-for="skin in group.options" :key="skin">{{ skin }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section">
<div class="detail-section-head">
<h2>号主备注</h2>
</div>
<p class="detail-description">{{ listing.description || "号主暂未填写详细说明。" }}</p>
</section>
<section v-if="detailScreenshots.length" class="detail-section">
<div class="detail-section-head">
<h2>账号截图</h2>
<span>{{ detailScreenshots.length }} 张</span>
</div>
<div class="detail-screenshot-grid">
<figure v-for="shot in detailScreenshots" :key="shot.url" class="detail-screenshot">
<img :src="shot.url" :alt="shot.label" loading="lazy" decoding="async" />
<figcaption>{{ shot.label }}</figcaption>
</figure>
</div>
</section>
</div>
</section>
<aside class="order-panel pc-order-card">
<h2>立即下单</h2>
<p class="order-safe-text">平台托管订单与押金,按平台交接流程完成账号使用。</p>
<el-form label-position="top">
<div class="order-total-box">
<div class="order-total-head">
<span>租赁价格</span>
<strong>¥{{ listingPrice(listing) }}</strong>
</div>
<div class="order-price-breakdown">
<div>
<span>基础租金</span>
<strong>¥{{ orderPriceBreakdown.rent }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>¥{{ orderPriceBreakdown.consumable }}</strong>
</div>
</div>
<em>押金另付 ¥{{ listing.deposit_amount }}</em>
</div>
<dl class="order-check-list">
<div>
<dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || "--" }}</dd>
</div>
<div>
<dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || "--" }}</dd>
</div>
<div>
<dt>预计可租</dt>
<dd>{{ formatEstimatedRentalDuration(listing) }}</dd>
</div>
</dl>
<el-button
type="warning"
size="large"
:loading="ordering"
:disabled="listing.in_transaction"
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
</el-button>
</el-form>
</aside>
</div>
</section>
</template>
<style scoped>
.pc-detail-layout {
grid-template-columns: minmax(0, 1fr) 420px;
align-items: start;
}
.pc-detail-main {
overflow: hidden;
}
.detail-hero-card {
position: relative;
display: grid;
height: 340px;
overflow: hidden;
background: #111827;
color: #ffffff;
}
.detail-hero-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-hero-card > span {
display: grid;
height: 340px;
place-items: center;
font-size: 32px;
font-weight: 900;
}
.detail-hero-card::after {
position: absolute;
inset: 0;
content: "";
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)),
linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%);
}
.detail-hero-overlay {
position: absolute;
inset: auto 0 0;
z-index: 1;
max-width: 860px;
padding: 28px;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.detail-tags span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-size: 13px;
font-weight: 900;
}
.detail-hero-overlay h1 {
margin: 0;
color: #ffffff;
font-size: 30px;
line-height: 1.25;
}
.detail-hero-overlay p {
margin: 8px 0 0;
color: rgba(255, 255, 255, 0.82);
font-size: 16px;
font-weight: 800;
}
.detail-body {
display: grid;
gap: 18px;
padding: 24px;
}
.detail-summary-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.detail-metric,
.detail-section {
border: 1px solid #edf0f4;
border-radius: 16px;
background: #ffffff;
}
.detail-metric {
padding: 18px;
background: #f8fafc;
}
.detail-metric span {
display: block;
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.detail-metric strong {
display: block;
margin-top: 8px;
color: #17233d;
font-size: 28px;
line-height: 1.1;
}
.detail-metric.is-coin strong {
color: #1477ff;
}
.detail-metric.is-price strong {
color: #ef4444;
}
.detail-section {
padding: 22px;
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.detail-section-head h2 {
margin: 0;
color: #17233d;
font-size: 20px;
}
.detail-section-head span {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.detail-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-chip-row span {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border: 1px solid rgba(255, 106, 0, 0.28);
border-radius: 8px;
background: #fff7ed;
color: #ea580c;
font-size: 13px;
font-weight: 900;
}
.detail-info-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 18px 0 0;
}
.detail-info-grid div {
min-width: 0;
border-radius: 12px;
background: #f8fafc;
padding: 14px;
}
.detail-info-grid dt {
color: #8b9cb5;
font-size: 12px;
font-weight: 900;
}
.detail-info-grid dd {
margin: 8px 0 0;
overflow-wrap: anywhere;
color: #17233d;
font-size: 15px;
font-weight: 900;
}
.detail-resource-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detail-resource-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
border-radius: 12px;
background: #f8fafc;
padding: 14px;
}
.detail-resource-card span {
min-width: 0;
color: #475569;
font-size: 14px;
font-weight: 900;
}
.detail-resource-card strong {
color: #17233d;
font-size: 18px;
}
.detail-resource-card em {
display: flex;
align-items: center;
justify-content: space-between;
grid-column: 1 / -1;
font-size: 12px;
font-style: normal;
font-weight: 900;
}
.detail-resource-card em b {
color: #ff6a00;
}
.detail-resource-card em small {
color: #17233d;
font-size: 13px;
font-weight: 900;
}
.skin-groups {
display: grid;
gap: 16px;
}
.skin-group h3 {
margin: 0 0 10px;
color: #475569;
font-size: 15px;
}
.detail-description {
margin: 0;
color: #52616f;
font-size: 15px;
line-height: 1.8;
}
.detail-screenshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.detail-screenshot {
margin: 0;
}
.detail-screenshot img {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: 12px;
border: 1px solid #edf0f4;
}
.detail-screenshot figcaption {
margin-top: 8px;
color: #64748b;
font-size: 13px;
font-weight: 800;
text-align: center;
}
.pc-order-card {
max-width: none;
border-radius: 20px;
}
.order-total-box {
margin-bottom: 14px;
}
.order-total-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(217, 119, 6, 0.14);
}
.order-total-head span {
color: #92400e;
font-size: 14px;
font-weight: 900;
}
.order-total-head strong {
margin: 0;
color: #ef4444;
font-size: 32px;
line-height: 1;
}
.order-price-breakdown {
display: grid;
gap: 8px;
padding: 12px 0;
}
.order-price-breakdown div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.order-price-breakdown span {
color: #8a5a12;
font-size: 13px;
font-weight: 900;
}
.order-price-breakdown strong {
margin: 0;
color: #17233d;
font-size: 16px;
line-height: 1.2;
}
.order-total-box > em {
padding-top: 10px;
border-top: 1px solid rgba(217, 119, 6, 0.14);
color: #92400e;
font-size: 13px;
font-weight: 900;
}
.order-check-list {
display: grid;
gap: 10px;
margin: 0 0 18px;
}
.order-check-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.order-check-list dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.order-check-list dd {
margin: 0;
color: #17233d;
font-size: 14px;
font-weight: 900;
text-align: right;
}
@media (max-width: 1180px) {
.pc-detail-layout,
.detail-screenshot-grid {
grid-template-columns: 1fr;
}
.detail-summary-row,
.detail-info-grid,
.detail-resource-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-hero-card,
.detail-hero-card img,
.detail-hero-card > span {
height: 300px;
}
.detail-hero-overlay {
padding: 24px;
}
.detail-hero-overlay h1 {
font-size: 26px;
}
}
@media (max-width: 720px) {
.detail-summary-row,
.detail-info-grid,
.detail-resource-grid {
grid-template-columns: 1fr;
}
}
</style>