Files
hfb_sys/frontend/src/features/listings/views/ListingDetailView.vue
T

983 lines
24 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, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { fetchListing, type Listing } from "@/features/listings/api/listings";
import { createOrder, fetchOrderAgreements, type OrderAgreements } from "@/features/orders/api/orders";
import { useSessionStore } from "@/stores/session";
import { roundMoney, formatMoney } from "@/shared/utils/money";
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 agreementsLoading = ref(false);
const agreementVisible = ref(false);
const agreements = ref<OrderAgreements | null>(null);
const virtualAgreementRead = ref(false);
const renterAgreementRead = ref(false);
const virtualAgreementChecked = ref(false);
const renterAgreementChecked = ref(false);
const virtualAgreementRef = ref<HTMLElement | null>(null);
const renterAgreementRef = ref<HTMLElement | null>(null);
const canCreateOrderAfterAgreement = computed(
() =>
virtualAgreementRead.value &&
renterAgreementRead.value &&
virtualAgreementChecked.value &&
renterAgreementChecked.value,
);
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 formatMoney(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: roundMoney(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;
}
if (session.realnameStatus !== "verified") {
try {
await session.loadMe();
} catch {
// 登录态失效时由全局请求拦截处理。
}
}
if (session.realnameStatus !== "verified") {
try {
await ElMessageBox.confirm("租号下单前需要完成实名认证。", "请先实名认证", {
confirmButtonText: "去认证",
cancelButtonText: "取消",
type: "warning",
});
await router.push({ path: "/realname", query: { redirect: route.fullPath } });
} catch {
// 用户取消认证时停留在当前页面。
}
return;
}
await openAgreementBeforeOrder();
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true;
try {
agreements.value = await fetchOrderAgreements();
virtualAgreementRead.value = false;
renterAgreementRead.value = false;
virtualAgreementChecked.value = false;
renterAgreementChecked.value = false;
agreementVisible.value = true;
await nextTick();
updateAgreementReadState("virtual");
updateAgreementReadState("renter");
} catch (error) {
ElMessage.error(readError(error, "协议加载失败"));
} finally {
agreementsLoading.value = false;
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning("请先阅读并勾选两份协议");
return;
}
agreementVisible.value = false;
await submitOrder();
}
async function submitOrder() {
if (!listing.value) 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 handleAgreementScroll(type: "virtual" | "renter") {
updateAgreementReadState(type);
}
function updateAgreementReadState(type: "virtual" | "renter") {
const el = type === "virtual" ? virtualAgreementRef.value : renterAgreementRef.value;
if (!el) return;
const read = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (type === "virtual") {
virtualAgreementRead.value = read;
} else {
renterAgreementRead.value = read;
}
}
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;
}
// 手动锁定/解锁背景滚动
watch(agreementVisible, (visible) => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
const pcMain = document.querySelector('.pc-main') as HTMLElement;
if (!pcMain) return;
if (visible) {
// 保存当前滚动位置
const scrollTop = pcMain.scrollTop || 0;
// 锁定主容器滚动
pcMain.style.overflow = 'hidden';
pcMain.style.position = 'fixed';
pcMain.style.top = `-${scrollTop}px`;
pcMain.style.left = '0';
pcMain.style.right = '0';
pcMain.style.width = '100%';
// 保存滚动位置供恢复使用
pcMain.dataset.scrollTop = String(scrollTop);
} else {
// 恢复主容器滚动
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10);
pcMain.style.overflow = '';
pcMain.style.position = '';
pcMain.style.top = '';
pcMain.style.left = '';
pcMain.style.right = '';
pcMain.style.width = '';
// 恢复滚动位置
pcMain.scrollTop = scrollTop;
delete pcMain.dataset.scrollTop;
}
});
function listingPrice(item: Listing) {
return formatMoney(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 || agreementsLoading"
:disabled="listing.in_transaction"
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
</el-button>
</el-form>
</aside>
</div>
<el-dialog
v-model="agreementVisible"
title="下单协议确认"
width="860px"
class="order-agreement-dialog"
:close-on-click-modal="false"
>
<div v-if="agreements" class="agreement-dialog-body">
<p class="agreement-tip">请完整阅读并勾选以下两份协议后继续下单。</p>
<div class="agreement-grid">
<section class="agreement-panel">
<h3>{{ agreements.virtual_asset_purchase.title }}</h3>
<div
ref="virtualAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('virtual')"
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<el-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead">
我已阅读并同意《{{ agreements.virtual_asset_purchase.title }}》
</el-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
<section class="agreement-panel">
<h3>{{ agreements.renter_agreement.title }}</h3>
<div
ref="renterAgreementRef"
class="agreement-content"
@scroll="handleAgreementScroll('renter')"
>
{{ agreements.renter_agreement.content }}
</div>
<el-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead">
我已阅读并同意《{{ agreements.renter_agreement.title }}》
</el-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
</section>
</div>
</div>
<template #footer>
<el-button @click="agreementVisible = false">取消</el-button>
<el-button
type="primary"
:loading="ordering"
:disabled="!canCreateOrderAfterAgreement"
@click="handleConfirmAgreementAndCreateOrder"
>
同意协议并下单
</el-button>
</template>
</el-dialog>
</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;
}
.agreement-dialog-body {
display: grid;
gap: 14px;
}
.agreement-tip {
margin: 0;
color: #64748b;
font-size: 14px;
font-weight: 700;
}
.agreement-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.agreement-panel {
display: grid;
gap: 10px;
min-width: 0;
}
.agreement-panel h3 {
margin: 0;
color: #17233d;
font-size: 16px;
font-weight: 900;
}
.agreement-content {
height: 260px;
overflow: auto;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
padding: 14px;
color: #1f2937;
font-size: 13px;
font-weight: 700;
line-height: 1.75;
white-space: pre-wrap;
}
.agreement-read-hint {
color: #f97316;
font-size: 12px;
font-weight: 800;
}
@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;
}
.agreement-grid {
grid-template-columns: 1fr;
}
}
</style>