增加前端格式检查配置

This commit is contained in:
yml2213
2026-06-08 06:40:33 +08:00
parent 79ea3a476c
commit 500f511185
150 changed files with 7254 additions and 4013 deletions
@@ -35,7 +35,10 @@ const {
resetFilters,
setFilterPopover,
closeFilterPopover,
} = useHomeFilters(publishOptions, computed(() => listings.value))
} = useHomeFilters(
publishOptions,
computed(() => listings.value)
)
const {
loading,
@@ -104,10 +107,7 @@ const zoneOptions = computed(() => [
async function loadHome() {
loading.value = true
try {
const [, config] = await Promise.all([
loadListingsPage(true),
fetchMobileHomeConfig(),
])
const [, config] = await Promise.all([loadListingsPage(true), fetchMobileHomeConfig()])
announcements.value = config.announcements
banners.value = config.banners
publishOptions.value = config.publish_options
@@ -179,11 +179,7 @@ loadHome()
<button type="button" @click="handleResetFilters">重置条件</button>
</div>
<div v-else v-loading="loading" class="enhanced-desktop-list">
<ListingCard
v-for="item in listings"
:key="item.id"
:listing="item"
/>
<ListingCard v-for="item in listings" :key="item.id" :listing="item" />
</div>
<div v-if="!loading && listings.length" class="infinite-load-state">
@@ -1,12 +1,16 @@
<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 { 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 { 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,
@@ -26,45 +30,45 @@ import {
getServerRegion,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
} 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 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);
renterAgreementChecked.value
)
const listing = ref<Listing | null>(null)
onMounted(async () => {
loading.value = true;
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id));
listing.value = await fetchListing(String(route.params.id))
} finally {
loading.value = false;
loading.value = false
}
});
})
const orderTotal = computed(() => {
if (!listing.value) return "0";
return formatMoney(getListingDisplayPrice(listing.value));
});
if (!listing.value) return '0'
return formatMoney(getListingDisplayPrice(listing.value))
})
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
@@ -72,260 +76,253 @@ const orderPriceBreakdown = computed(() => {
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] || "";
});
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);
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: "日损耗",
value: dailyLoss ? `${dailyLoss}/天` : "--",
tone: "coin",
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: "价格", value: `¥${orderTotal.value}`, tone: "price" },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
{ label: '价格', value: `¥${orderTotal.value}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]
})
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const groupedScreenshots = readGroupedScreenshots(listing.value);
if (groupedScreenshots.length) return groupedScreenshots;
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}));
});
}))
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups;
if (typeof groups !== "object" || groups === null) return [];
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
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 validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
{ 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 validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}));
});
}))
})
}
const detailSkinGroups = computed(() => {
if (!listing.value) return [];
const groups = listing.value.asset_summary?.skin_groups;
if (typeof groups !== "object" || groups === null) return [];
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: "武器皮肤",
};
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")
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter((group) => group.options.length);
});
.filter(group => group.options.length)
})
const accountRows = computed(() => {
if (!listing.value) return [];
const regions = assetRegions(listing.value);
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") || "无" },
];
});
{ 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 (!listing.value) return
if (!session.token) {
await router.push({ path: "/login", query: { redirect: route.fullPath } });
return;
await router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe();
await session.loadMe()
} catch {
// 登录态失效时由全局请求拦截处理。
}
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await ElMessageBox.confirm("租号下单前需要完成实名认证。", "请先实名认证", {
confirmButtonText: "去认证",
cancelButtonText: "取消",
type: "warning",
});
await router.push({ path: "/realname", query: { redirect: route.fullPath } });
await ElMessageBox.confirm('租号下单前需要完成实名认证。', '请先实名认证', {
confirmButtonText: '去认证',
cancelButtonText: '取消',
type: 'warning',
})
await router.push({ path: '/realname', query: { redirect: route.fullPath } })
} catch {
// 用户取消认证时停留在当前页面。
}
return;
return
}
await openAgreementBeforeOrder();
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true;
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");
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, "协议加载失败"));
ElMessage.error(readError(error, '协议加载失败'))
} finally {
agreementsLoading.value = false;
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
ElMessage.warning("请先阅读并勾选两份协议");
return;
ElMessage.warning('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false;
await submitOrder();
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return;
ordering.value = true;
if (!listing.value) return
ordering.value = true
try {
const order = await createOrder(listing.value.id);
ElMessage.success("订单已创建,请完成支付");
await router.push(`/orders/${order.id}`);
const order = await createOrder(listing.value.id)
ElMessage.success('订单已创建,请完成支付')
await router.push(`/orders/${order.id}`)
} catch (error) {
ElMessage.error(readError(error, "下单失败"));
ElMessage.error(readError(error, '下单失败'))
} finally {
ordering.value = false;
ordering.value = false
}
}
function handleAgreementScroll(type: "virtual" | "renter") {
updateAgreementReadState(type);
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;
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;
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;
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback;
return fallback
}
// 手动锁定/解锁背景滚动
watch(agreementVisible, (visible) => {
watch(agreementVisible, visible => {
// 只锁定 .pc-main 容器,不影响 body 和 sticky 导航栏
const pcMain = document.querySelector('.pc-main') as HTMLElement;
if (!pcMain) return;
const pcMain = document.querySelector('.pc-main') as HTMLElement
if (!pcMain) return
if (visible) {
// 保存当前滚动位置
const scrollTop = pcMain.scrollTop || 0;
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.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);
pcMain.dataset.scrollTop = String(scrollTop)
} else {
// 恢复主容器滚动
const scrollTop = parseInt(pcMain.dataset.scrollTop || '0', 10);
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.style.overflow = ''
pcMain.style.position = ''
pcMain.style.top = ''
pcMain.style.left = ''
pcMain.style.right = ''
pcMain.style.width = ''
// 恢复滚动位置
pcMain.scrollTop = scrollTop;
delete pcMain.dataset.scrollTop;
pcMain.scrollTop = scrollTop
delete pcMain.dataset.scrollTop
}
});
})
function listingPrice(item: Listing) {
return formatMoney(getListingDisplayPrice(item));
return formatMoney(getListingDisplayPrice(item))
}
</script>
<template>
<section class="pc-detail page" v-loading="loading">
<div class="anti-fraud-strip compact">
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
<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)"
/>
<img v-if="coverURL" :src="coverURL" :alt="getListingTitle(listing)" />
<span v-else>HFB ACCOUNT</span>
<div class="detail-hero-overlay">
<div class="detail-tags">
@@ -340,7 +337,12 @@ function listingPrice(item: Listing) {
<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}`">
<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>
@@ -349,7 +351,7 @@ function listingPrice(item: Listing) {
<section class="detail-section">
<div class="detail-section-head">
<h2>账号资料</h2>
<span>{{ listing.game_name || "三角洲行动" }}</span>
<span>{{ listing.game_name || '三角洲行动' }}</span>
</div>
<div class="detail-chip-row">
<span v-for="chip in getListingChips(listing)" :key="chip.label">
@@ -370,13 +372,17 @@ function listingPrice(item: Listing) {
<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">
<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>
<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-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
@@ -402,7 +408,7 @@ function listingPrice(item: Listing) {
<div class="detail-section-head">
<h2>号主备注</h2>
</div>
<p class="detail-description">{{ listing.description || "号主暂未填写详细说明。" }}</p>
<p class="detail-description">{{ listing.description || '号主暂未填写详细说明。' }}</p>
</section>
<section v-if="detailScreenshots.length" class="detail-section">
@@ -444,11 +450,11 @@ function listingPrice(item: Listing) {
<dl class="order-check-list">
<div>
<dt>账号区服</dt>
<dd>{{ getServerRegion(listing) || "--" }}</dd>
<dd>{{ getServerRegion(listing) || '--' }}</dd>
</div>
<div>
<dt>上号方式</dt>
<dd>{{ getLoginMethod(listing) || "--" }}</dd>
<dd>{{ getLoginMethod(listing) || '--' }}</dd>
</div>
<div>
<dt>预计可租</dt>
@@ -463,7 +469,7 @@ function listingPrice(item: Listing) {
class="full-control"
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
{{ listing.in_transaction ? '交易中' : '立即下单' }}
</el-button>
</el-form>
</aside>
@@ -560,7 +566,7 @@ function listingPrice(item: Listing) {
.detail-hero-card::after {
position: absolute;
inset: 0;
content: "";
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%);
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from 'vue'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/features/listings/api/listingOptions";
import { fetchListings, type Listing } from "@/features/listings/api/listings";
} from '@/features/listings/api/listingOptions'
import { fetchListings, type Listing } from '@/features/listings/api/listings'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from "@/features/listings/api/homeConfig";
} from '@/features/listings/api/homeConfig'
import {
assetRegions,
formatHafCoinM,
@@ -29,296 +29,285 @@ import {
hasGiftResources,
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
import { listingStatusLabel } from "@/utils/statusLabels";
} from '@/utils/listingDisplay'
import { listingStatusLabel } from '@/utils/statusLabels'
/* ------------------------------------------------------------------ */
/* 数据加载 */
/* ------------------------------------------------------------------ */
const loading = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const announcements = ref<string[]>(defaultHomeAnnouncements);
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
const loading = ref(false)
const loadFailed = ref(false)
const listings = ref<Listing[]>([])
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
onMounted(() => {
loadListings();
loadHomeConfig();
});
loadListings()
loadHomeConfig()
})
async function loadListings() {
loading.value = true;
loadFailed.value = false;
loading.value = true
loadFailed.value = false
try {
listings.value = await fetchListings();
listings.value = await fetchListings()
} catch {
loadFailed.value = true;
loadFailed.value = true
} finally {
loading.value = false;
loading.value = false
}
}
async function loadHomeConfig() {
try {
const config = await fetchMobileHomeConfig();
announcements.value = config.announcements;
bannerSlides.value = config.banners;
publishOptions.value = config.publish_options;
const config = await fetchMobileHomeConfig()
announcements.value = config.announcements
bannerSlides.value = config.banners
publishOptions.value = config.publish_options
} catch {
announcements.value = defaultHomeAnnouncements;
bannerSlides.value = defaultHomeBanners;
publishOptions.value = emptyListingPublishOptions;
announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions
}
}
/* ------------------------------------------------------------------ */
/* 排序 */
/* ------------------------------------------------------------------ */
const sortBy = ref("comprehensive");
const sortBy = ref('comprehensive')
const sortOptions = [
{ key: "comprehensive", label: "综合排序" },
{ key: "published", label: "发布时间" },
{ key: "awmDesc", label: "AWM数量" },
{ key: "priceAsc", label: "价格最低" },
{ key: "priceDesc", label: "价格最高" },
];
{ key: 'comprehensive', label: '综合排序' },
{ key: 'published', label: '发布时间' },
{ key: 'awmDesc', label: 'AWM数量' },
{ key: 'priceAsc', label: '价格最低' },
{ key: 'priceDesc', label: '价格最高' },
]
const activeSortLabel = computed(
() => sortOptions.find((o) => o.key === sortBy.value)?.label || "综合排序"
);
() => sortOptions.find(o => o.key === sortBy.value)?.label || '综合排序'
)
function selectSort(key: string) {
sortBy.value = key;
sortOpen.value = false;
sortBy.value = key
sortOpen.value = false
}
/* ------------------------------------------------------------------ */
/* 筛选 — 复用移动端 FilterSection 体系 */
/* ------------------------------------------------------------------ */
type SelectedFilters = Record<string, string[]>;
type RangeFilters = Record<string, { min: string; max: string }>;
type RangePresets = Record<
string,
Array<{ label: string; min: string; max: string }>
>;
type SelectedFilters = Record<string, string[]>
type RangeFilters = Record<string, { min: string; max: string }>
type RangePresets = Record<string, Array<{ label: string; min: string; max: string }>>
const selectedFilters = ref<SelectedFilters>({});
const rangeFilters = ref<RangeFilters>({});
const searchValue = ref("");
const sortOpen = ref(false);
const filterOpen = ref(false);
const selectedFilters = ref<SelectedFilters>({})
const rangeFilters = ref<RangeFilters>({})
const searchValue = ref('')
const sortOpen = ref(false)
const filterOpen = ref(false)
const rangePresets: RangePresets = {
coin: [
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200-300", min: "200", max: "300" },
{ label: "300-500", min: "300", max: "500" },
{ label: "500以上", min: "500", max: "" },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200-300', min: '200', max: '300' },
{ label: '300-500', min: '300', max: '500' },
{ label: '500以上', min: '500', max: '' },
],
resource_awmAmmo: [
{ label: "0-20", min: "0", max: "20" },
{ label: "20-50", min: "20", max: "50" },
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200以上", min: "200", max: "" },
{ label: '0-20', min: '0', max: '20' },
{ label: '20-50', min: '20', max: '50' },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200以上', min: '200', max: '' },
],
};
}
const serverFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.server_options
.map((item) => item.trim())
.filter(Boolean)
)
);
uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
)
const loginMethodFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.login_method_options
.map((item) => item.trim())
.filter(Boolean)
)
);
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
)
type FilterSection =
| {
key: string;
title: string;
type: "range";
unit?: string;
minPlaceholder?: string;
maxPlaceholder?: string;
key: string
title: string
type: 'range'
unit?: string
minPlaceholder?: string
maxPlaceholder?: string
}
| { key: string; title: string; type: "chips"; options: string[] };
| { key: string; title: string; type: 'chips'; options: string[] }
const filterSections = computed<FilterSection[]>(() => [
{
key: "price",
title: "价格区间",
type: "range",
unit: "元",
minPlaceholder: "最低价",
maxPlaceholder: "最高价",
key: 'price',
title: '价格区间',
type: 'range',
unit: '元',
minPlaceholder: '最低价',
maxPlaceholder: '最高价',
},
{
key: "coin",
title: "哈夫币数量",
type: "range",
unit: "M",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'coin',
title: '哈夫币数量',
type: 'range',
unit: 'M',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: "server",
title: "区服",
type: "chips",
key: 'server',
title: '区服',
type: 'chips',
options: serverFilterOptions.value,
},
{
key: "login",
title: "上号方式",
type: "chips",
key: 'login',
title: '上号方式',
type: 'chips',
options: loginMethodFilterOptions.value,
},
{
key: "insurance",
title: "保险",
type: "chips",
key: 'insurance',
title: '保险',
type: 'chips',
options: publishOptions.value.insurance_options,
},
{
key: "stamina",
title: "体力",
type: "chips",
key: 'stamina',
title: '体力',
type: 'chips',
options: publishOptions.value.level_options,
},
{
key: "load",
title: "负重",
type: "chips",
key: 'load',
title: '负重',
type: 'chips',
options: publishOptions.value.level_options,
},
...publishOptions.value.quantity_items.map((item) => ({
...publishOptions.value.quantity_items.map(item => ({
key: `resource_${item.key}`,
title: item.label,
type: "range" as const,
type: 'range' as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
minPlaceholder: '最低',
maxPlaceholder: '最高',
})),
...publishOptions.value.skin_groups.map((group) => ({
...publishOptions.value.skin_groups.map(group => ({
key: group.key,
title: group.title,
type: "chips" as const,
type: 'chips' as const,
options: group.options,
})),
{
key: "secretKd",
title: "绝密KD",
type: "range",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'secretKd',
title: '绝密KD',
type: 'range',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{
key: "rank",
title: "段位",
type: "chips",
key: 'rank',
title: '段位',
type: 'chips',
options: publishOptions.value.rank_options,
},
{
key: "deposit",
title: "押金",
type: "range",
unit: "元",
minPlaceholder: "最低",
maxPlaceholder: "最高",
key: 'deposit',
title: '押金',
type: 'range',
unit: '元',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
]);
])
/* 筛选交互 */
const activeFilterSection = ref("price");
const filterContentRef = ref<HTMLElement | null>(null);
const filterSectionRefs = new Map<string, HTMLElement>();
const filterTabRefs = new Map<string, HTMLElement>();
const activeFilterSection = ref('price')
const filterContentRef = ref<HTMLElement | null>(null)
const filterSectionRefs = new Map<string, HTMLElement>()
const filterTabRefs = new Map<string, HTMLElement>()
function toggleChip(sectionKey: string, value: string) {
const selected = selectedFilters.value[sectionKey] || [];
const selected = selectedFilters.value[sectionKey] || []
const next = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next };
? selected.filter(item => item !== value)
: [...selected, value]
selectedFilters.value = { ...selectedFilters.value, [sectionKey]: next }
}
function updateRange(sectionKey: string, side: "min" | "max", value: string) {
const current = rangeFilters.value[sectionKey] || { min: "", max: "" };
function updateRange(sectionKey: string, side: 'min' | 'max', value: string) {
const current = rangeFilters.value[sectionKey] || { min: '', max: '' }
rangeFilters.value = {
...rangeFilters.value,
[sectionKey]: { ...current, [side]: value },
};
}
}
function applyRangePreset(sectionKey: string, min: string, max: string) {
rangeFilters.value = {
...rangeFilters.value,
[sectionKey]: { min, max },
};
}
}
function isRangePresetActive(sectionKey: string, min: string, max: string) {
const range = rangeFilters.value[sectionKey];
return range?.min === min && range?.max === max;
const range = rangeFilters.value[sectionKey]
return range?.min === min && range?.max === max
}
function isSkinGroupKey(key: string) {
return publishOptions.value.skin_groups.some((group) => group.key === key);
return publishOptions.value.skin_groups.some(group => group.key === key)
}
function setFilterSectionRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) filterSectionRefs.set(key, el);
else filterSectionRefs.delete(key);
if (el instanceof HTMLElement) filterSectionRefs.set(key, el)
else filterSectionRefs.delete(key)
}
function setFilterTabRef(key: string, el: Element | null) {
if (el instanceof HTMLElement) filterTabRefs.set(key, el);
else filterTabRefs.delete(key);
if (el instanceof HTMLElement) filterTabRefs.set(key, el)
else filterTabRefs.delete(key)
}
function scrollToFilterSection(key: string) {
const container = filterContentRef.value;
const target = filterSectionRefs.get(key);
if (!container || !target) return;
activeFilterSection.value = key;
const container = filterContentRef.value
const target = filterSectionRefs.get(key)
if (!container || !target) return
activeFilterSection.value = key
container.scrollTo({
top: Math.max(target.offsetTop - 8, 0),
behavior: "smooth",
});
behavior: 'smooth',
})
}
function handleFilterScroll() {
const container = filterContentRef.value;
if (!container) return;
const anchorTop = container.scrollTop + 16;
let activeKey = filterSections.value[0]?.key || "";
const container = filterContentRef.value
if (!container) return
const anchorTop = container.scrollTop + 16
let activeKey = filterSections.value[0]?.key || ''
for (const section of filterSections.value) {
const el = filterSectionRefs.get(section.key);
if (el && el.offsetTop <= anchorTop) activeKey = section.key;
const el = filterSectionRefs.get(section.key)
if (el && el.offsetTop <= anchorTop) activeKey = section.key
}
if (activeKey && activeKey !== activeFilterSection.value) {
activeFilterSection.value = activeKey;
activeFilterSection.value = activeKey
}
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
sortOpen.value = !sortOpen.value
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
selectedFilters.value = {}
rangeFilters.value = {}
searchValue.value = ''
}
/* ------------------------------------------------------------------ */
@@ -328,64 +317,59 @@ const activeFilterCount = computed(() => {
const chipCount = Object.values(selectedFilters.value).reduce(
(sum, values) => sum + values.length,
0
);
)
const rangeCount = Object.values(rangeFilters.value).filter(
(range) => range.min || range.max
).length;
return chipCount + rangeCount;
});
range => range.min || range.max
).length
return chipCount + rangeCount
})
const filteredListings = computed(() => {
const keyword = searchValue.value.trim().toLowerCase();
const filtered = listings.value.filter((item) => {
if (!matchesFilters(item)) return false;
if (!keyword) return true;
return searchText(item).includes(keyword);
});
return sortListings(filtered);
});
const keyword = searchValue.value.trim().toLowerCase()
const filtered = listings.value.filter(item => {
if (!matchesFilters(item)) return false
if (!keyword) return true
return searchText(item).includes(keyword)
})
return sortListings(filtered)
})
function matchesFilters(item: Listing) {
const chipOk = Object.entries(selectedFilters.value).every(
([key, values]) => {
if (!values.length) return true;
if (key === "server") return values.includes(getServerRegion(item));
if (key === "login") return values.includes(getLoginMethod(item));
if (key === "insurance")
return values.includes(readAssetString(item, "season_insurance"));
if (key === "stamina")
return values.includes(readAssetString(item, "stamina_level"));
if (key === "load")
return values.includes(readAssetString(item, "load_level"));
if (key === "rank") return values.includes(item.rank_level);
if (isSkinGroupKey(key)) {
return getSkinGroup(item, key).some((skin) => values.includes(skin));
}
return true;
const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
if (!values.length) return true
if (key === 'server') return values.includes(getServerRegion(item))
if (key === 'login') return values.includes(getLoginMethod(item))
if (key === 'insurance') return values.includes(readAssetString(item, 'season_insurance'))
if (key === 'stamina') return values.includes(readAssetString(item, 'stamina_level'))
if (key === 'load') return values.includes(readAssetString(item, 'load_level'))
if (key === 'rank') return values.includes(item.rank_level)
if (isSkinGroupKey(key)) {
return getSkinGroup(item, key).some(skin => values.includes(skin))
}
);
if (!chipOk) return false;
return true
})
if (!chipOk) return false
return Object.entries(rangeFilters.value).every(([key, range]) => {
if (!range.min && !range.max) return true;
let value = 0;
if (key === "price") value = getListingDisplayPrice(item);
if (key === "coin") value = getCoinM(item);
if (key === "secretKd") value = readAssetNumber(item, "secret_kd");
if (key === "deposit") value = Number(item.deposit_amount || 0);
if (key.startsWith("resource_")) {
value = getResourceQuantity(item, key.replace("resource_", ""));
if (!range.min && !range.max) return true
let value = 0
if (key === 'price') value = getListingDisplayPrice(item)
if (key === 'coin') value = getCoinM(item)
if (key === 'secretKd') value = readAssetNumber(item, 'secret_kd')
if (key === 'deposit') value = Number(item.deposit_amount || 0)
if (key.startsWith('resource_')) {
value = getResourceQuantity(item, key.replace('resource_', ''))
}
return inRange(value, range);
});
return inRange(value, range)
})
}
function inRange(value: number, range: { min: string; max: string }) {
const min = range.min === "" ? undefined : Number(range.min);
const max = range.max === "" ? undefined : Number(range.max);
if (min !== undefined && Number.isFinite(min) && value < min) return false;
if (max !== undefined && Number.isFinite(max) && value > max) return false;
return true;
const min = range.min === '' ? undefined : Number(range.min)
const max = range.max === '' ? undefined : Number(range.max)
if (min !== undefined && Number.isFinite(min) && value < min) return false
if (max !== undefined && Number.isFinite(max) && value > max) return false
return true
}
function searchText(item: Listing) {
@@ -395,53 +379,47 @@ function searchText(item: Listing) {
getServerRegion(item),
getLoginMethod(item),
item.rank_level,
readAssetString(item, "season_insurance"),
readAssetString(item, "stamina_level"),
readAssetString(item, "load_level"),
readAssetString(item, 'season_insurance'),
readAssetString(item, 'stamina_level'),
readAssetString(item, 'load_level'),
formatHafCoinM(getCoinWan(item)),
assetRegions(item).join(" "),
...publishOptions.value.skin_groups.map((group) =>
getSkinGroup(item, group.key).join(" ")
),
assetRegions(item).join(' '),
...publishOptions.value.skin_groups.map(group => getSkinGroup(item, group.key).join(' ')),
]
.join(" ")
.toLowerCase();
.join(' ')
.toLowerCase()
}
function sortListings(items: Listing[]) {
const sorted = [...items];
if (sortBy.value === "comprehensive") return sorted;
if (sortBy.value === "priceAsc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
);
const sorted = [...items]
if (sortBy.value === 'comprehensive') return sorted
if (sortBy.value === 'priceAsc') {
return sorted.sort((a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b))
}
if (sortBy.value === "priceDesc") {
return sorted.sort(
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
);
if (sortBy.value === 'priceDesc') {
return sorted.sort((a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a))
}
if (sortBy.value === "awmDesc") {
if (sortBy.value === 'awmDesc') {
return sorted.sort(
(a, b) =>
getResourceQuantity(b, "awmAmmo") - getResourceQuantity(a, "awmAmmo") ||
getResourceQuantity(b, 'awmAmmo') - getResourceQuantity(a, 'awmAmmo') ||
getCoinWan(b) - getCoinWan(a)
);
)
}
return sorted.sort((a, b) => {
const bTime = Date.parse(b.published_at || b.created_at || "") || 0;
const aTime = Date.parse(a.published_at || a.created_at || "") || 0;
return bTime - aTime || b.id - a.id;
});
const bTime = Date.parse(b.published_at || b.created_at || '') || 0
const aTime = Date.parse(a.published_at || a.created_at || '') || 0
return bTime - aTime || b.id - a.id
})
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
const unit = price.split('/')[1]?.trim()
return unit || undefined
}
</script>
@@ -450,12 +428,7 @@ function parseQuantityUnit(price: string) {
<!-- 防骗提示 -->
<div class="fraud-tip">
<span class="fraud-dot"></span>
<span
v-for="(text, idx) in announcements"
:key="idx"
class="fraud-text"
>{{ text }}</span
>
<span v-for="(text, idx) in announcements" :key="idx" class="fraud-text">{{ text }}</span>
</div>
<!-- 页头 -->
@@ -479,11 +452,7 @@ function parseQuantityUnit(price: string) {
<input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" />
</div>
<button
type="button"
class="toolbar-btn sort-btn"
@click="toggleSortPanel"
>
<button type="button" class="toolbar-btn sort-btn" @click="toggleSortPanel">
<span>{{ activeSortLabel }}</span>
<svg
:class="{ rotated: sortOpen }"
@@ -500,11 +469,7 @@ function parseQuantityUnit(price: string) {
</svg>
</button>
<button
type="button"
class="toolbar-btn filter-btn"
@click="filterOpen = !filterOpen"
>
<button type="button" class="toolbar-btn filter-btn" @click="filterOpen = !filterOpen">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path
fill-rule="evenodd"
@@ -550,13 +515,7 @@ function parseQuantityUnit(price: string) {
<aside v-if="filterOpen" class="filter-sidebar">
<header class="filter-sidebar-header">
<h2>筛选</h2>
<button
type="button"
class="filter-close-btn"
@click="filterOpen = false"
>
</button>
<button type="button" class="filter-close-btn" @click="filterOpen = false"></button>
</header>
<div class="filter-sidebar-body">
@@ -564,7 +523,7 @@ function parseQuantityUnit(price: string) {
<button
v-for="section in filterSections"
:key="section.key"
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
:ref="el => setFilterTabRef(section.key, el as Element | null)"
type="button"
:class="{ active: activeFilterSection === section.key }"
@click="scrollToFilterSection(section.key)"
@@ -581,14 +540,11 @@ function parseQuantityUnit(price: string) {
<div
v-for="section in filterSections"
:key="section.key"
:ref="(el) => setFilterSectionRef(section.key, el as Element | null)"
:ref="el => setFilterSectionRef(section.key, el as Element | null)"
class="filter-block"
>
<h3>{{ section.title }}</h3>
<p
v-if="section.type === 'range' && section.unit"
class="filter-unit"
>
<p v-if="section.type === 'range' && section.unit" class="filter-unit">
单位{{ section.unit }}
</p>
@@ -600,11 +556,7 @@ function parseQuantityUnit(price: string) {
inputmode="decimal"
:placeholder="section.minPlaceholder || '最低'"
@input="
updateRange(
section.key,
'min',
($event.target as HTMLInputElement).value
)
updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)
"
/>
<span class="range-sep"></span>
@@ -614,32 +566,19 @@ function parseQuantityUnit(price: string) {
inputmode="decimal"
:placeholder="section.maxPlaceholder || '最高'"
@input="
updateRange(
section.key,
'max',
($event.target as HTMLInputElement).value
)
updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)
"
/>
</div>
<div
v-if="rangePresets[section.key]?.length"
class="chip-grid range-preset-grid"
>
<div v-if="rangePresets[section.key]?.length" class="chip-grid range-preset-grid">
<button
v-for="preset in rangePresets[section.key]"
:key="`${section.key}-${preset.label}`"
type="button"
:class="{
active: isRangePresetActive(
section.key,
preset.min,
preset.max
),
active: isRangePresetActive(section.key, preset.min, preset.max),
}"
@click="
applyRangePreset(section.key, preset.min, preset.max)
"
@click="applyRangePreset(section.key, preset.min, preset.max)"
>
{{ preset.label }}
</button>
@@ -664,35 +603,23 @@ function parseQuantityUnit(price: string) {
</div>
<footer class="filter-sidebar-footer">
<button type="button" class="reset-btn" @click="clearFilters">
重置
</button>
<button type="button" class="confirm-btn" @click="filterOpen = false">
确定
</button>
<button type="button" class="reset-btn" @click="clearFilters">重置</button>
<button type="button" class="confirm-btn" @click="filterOpen = false">确定</button>
</footer>
</aside>
</Transition>
<!-- 筛选遮罩 -->
<Transition name="overlay">
<div
v-if="filterOpen"
class="filter-overlay"
@click="filterOpen = false"
></div>
<div v-if="filterOpen" class="filter-overlay" @click="filterOpen = false"></div>
</Transition>
<!-- 加载 / 错误 / 空状态 -->
<div v-if="loading" class="state-loading">正在加载优质账号...</div>
<div v-else-if="loadFailed" class="state-error">
接口暂不可用请稍后刷新
</div>
<div v-else-if="loadFailed" class="state-error">接口暂不可用请稍后刷新</div>
<div v-else-if="filteredListings.length === 0" class="state-empty">
<p>没有符合条件的账号</p>
<button type="button" class="reset-btn" @click="clearFilters">
重置条件
</button>
<button type="button" class="reset-btn" @click="clearFilters">重置条件</button>
</div>
<!-- 列表 -->
@@ -712,10 +639,7 @@ function parseQuantityUnit(price: string) {
decoding="async"
/>
<span v-else></span>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="cover-labels"
>
<div v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)" class="cover-labels">
<em v-if="hasGiftResources(item)">有赠送</em>
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
</div>
@@ -726,14 +650,10 @@ function parseQuantityUnit(price: string) {
<div class="resource-badge-row">
<span class="trust-badge">押金秒退</span>
<span class="server-badge">{{ getServerRegion(item) }}</span>
<span v-if="getLoginMethod(item)" class="server-badge">{{
getLoginMethod(item)
}}</span>
<span v-if="getLoginMethod(item)" class="server-badge">{{ getLoginMethod(item) }}</span>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`"
>{{ chip.label }}:{{ chip.value }}</span
>
</div>
@@ -797,7 +717,7 @@ function parseQuantityUnit(price: string) {
}
.fraud-text + .fraud-text::before {
content: " | ";
content: ' | ';
color: #d4c47a;
}
@@ -848,7 +768,9 @@ function parseQuantityUnit(price: string) {
border-radius: 10px;
background: #f4f6f8;
border: 1px solid transparent;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.search-box:focus-within {
@@ -890,7 +812,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.toolbar-btn:hover {
@@ -950,7 +874,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s, color 0.12s;
transition:
background 0.12s,
color 0.12s;
}
.sort-panel button:hover {
@@ -972,7 +898,9 @@ function parseQuantityUnit(price: string) {
.sort-panel-enter-active,
.sort-panel-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.sort-panel-enter-from,
@@ -1051,7 +979,9 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s;
transition:
background 0.12s,
color 0.12s;
}
.filter-tabs button:hover {
@@ -1114,7 +1044,9 @@ function parseQuantityUnit(price: string) {
text-align: center;
font-size: 13px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.range-editor input:focus {
@@ -1139,7 +1071,10 @@ function parseQuantityUnit(price: string) {
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
transition:
background 0.12s,
color 0.12s,
box-shadow 0.12s;
}
.chip-grid button:hover {
@@ -1255,7 +1190,9 @@ function parseQuantityUnit(price: string) {
border: 1px solid #eef1f5;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s, border-color 0.15s;
transition:
box-shadow 0.15s,
border-color 0.15s;
}
.resource-card:hover {
@@ -36,14 +36,21 @@ const emit = defineEmits<{
}>()
const activeCount = computed(() => {
const chipCount = Object.values(props.selectedFilters).reduce((sum, values) => sum + values.length, 0)
const rangeCount = Object.values(props.rangeFilters).filter((range) => range.min || range.max).length
const chipCount = Object.values(props.selectedFilters).reduce(
(sum, values) => sum + values.length,
0
)
const rangeCount = Object.values(props.rangeFilters).filter(
range => range.min || range.max
).length
return chipCount + rangeCount
})
function toggleChip(sectionKey: string, value: string) {
const selected = props.selectedFilters[sectionKey] || []
const next = selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]
const next = selected.includes(value)
? selected.filter(item => item !== value)
: [...selected, value]
emit('update:selectedFilters', { ...props.selectedFilters, [sectionKey]: next })
}
@@ -140,7 +140,7 @@
.mobile-banner.has-image::after {
position: absolute;
inset: 0;
content: "";
content: '';
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.72));
}
@@ -1,24 +1,26 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, useRouter } from "vue-router";
import { showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat } from "@/features/chats/api/chats";
import { ensureSupportChat } from '@/features/chats/api/chats'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
} from "@/features/listings/api/listingOptions";
import { fetchListingsPage, type Listing, type PublicListingQuery } from "@/features/listings/api/listings";
} from '@/features/listings/api/listingOptions'
import {
fetchListingsPage,
type Listing,
type PublicListingQuery,
} from '@/features/listings/api/listings'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
fetchMobileHomeConfig,
type HomeBannerSlide,
} from "@/features/listings/api/homeConfig";
import MobileHomeFilterSheet, {
type FilterSection,
} from "./MobileHomeFilterSheet.vue";
} from '@/features/listings/api/homeConfig'
import MobileHomeFilterSheet, { type FilterSection } from './MobileHomeFilterSheet.vue'
import {
getListingChips,
getListingDisplayPrice,
@@ -28,237 +30,256 @@ import {
getServerRegion,
hasAcceleratedSaleRatio,
hasGiftResources,
} from "@/utils/listingDisplay";
import { useSessionStore } from "@/stores/session";
} from '@/utils/listingDisplay'
import { useSessionStore } from '@/stores/session'
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const loadingMore = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
const totalListings = ref(0);
const currentPage = ref(1);
const hasMoreListings = ref(true);
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions);
const sortOpen = ref(false);
const activeSort = ref("comprehensive");
const filterOpen = ref(false);
const selectedFilters = ref<Record<string, string[]>>({});
const rangeFilters = ref<Record<string, { min: string; max: string }>>({});
const refreshing = ref(false);
const searchValue = ref("");
const announcements = ref<string[]>(defaultHomeAnnouncements);
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
const supportLoading = ref(false);
const mobilePageSize = 10;
let listingRequestSeq = 0;
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const loadingMore = ref(false)
const loadFailed = ref(false)
const listings = ref<Listing[]>([])
const totalListings = ref(0)
const currentPage = ref(1)
const hasMoreListings = ref(true)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortOpen = ref(false)
const activeSort = ref('comprehensive')
const filterOpen = ref(false)
const selectedFilters = ref<Record<string, string[]>>({})
const rangeFilters = ref<Record<string, { min: string; max: string }>>({})
const refreshing = ref(false)
const searchValue = ref('')
const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
const supportLoading = ref(false)
const mobilePageSize = 10
let listingRequestSeq = 0
const sortOptions = [
{ key: "comprehensive", label: "综合排序" },
{ key: "published", label: "发布时间" },
{ key: "awmDesc", label: "AWM数量" },
{ key: "priceAsc", label: "价格最低" },
{ key: "priceDesc", label: "价格最高" },
];
{ key: 'comprehensive', label: '综合排序' },
{ key: 'published', label: '发布时间' },
{ key: 'awmDesc', label: 'AWM数量' },
{ key: 'priceAsc', label: '价格最低' },
{ key: 'priceDesc', label: '价格最高' },
]
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
coin: [
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200-300", min: "200", max: "300" },
{ label: "300-500", min: "300", max: "500" },
{ label: "500以上", min: "500", max: "" },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200-300', min: '200', max: '300' },
{ label: '300-500', min: '300', max: '500' },
{ label: '500以上', min: '500', max: '' },
],
resource_awmAmmo: [
{ label: "0-20", min: "0", max: "20" },
{ label: "20-50", min: "20", max: "50" },
{ label: "50-100", min: "50", max: "100" },
{ label: "100-200", min: "100", max: "200" },
{ label: "200以上", min: "200", max: "" },
{ label: '0-20', min: '0', max: '20' },
{ label: '20-50', min: '20', max: '50' },
{ label: '50-100', min: '50', max: '100' },
{ label: '100-200', min: '100', max: '200' },
{ label: '200以上', min: '200', max: '' },
],
};
}
const activeSortLabel = computed(
() =>
sortOptions.find((option) => option.key === activeSort.value)?.label ||
"综合排序"
);
() => sortOptions.find(option => option.key === activeSort.value)?.label || '综合排序'
)
async function handleSupportClick() {
if (!session.isLoggedIn) {
router.push({ path: "/m/login", query: { redirect: router.currentRoute.value.fullPath } });
return;
router.push({ path: '/m/login', query: { redirect: router.currentRoute.value.fullPath } })
return
}
if (supportLoading.value) return;
supportLoading.value = true;
if (supportLoading.value) return
supportLoading.value = true
try {
const chat = await ensureSupportChat();
router.push(`/m/chats/${chat.id}`);
const chat = await ensureSupportChat()
router.push(`/m/chats/${chat.id}`)
} catch {
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
showToast({ message: '联系客服失败,请稍后重试', icon: 'cross' })
} finally {
supportLoading.value = false;
supportLoading.value = false
}
}
const serverFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.server_options
.map((item) => item.trim())
.filter(Boolean)
)
);
uniqueOptions(publishOptions.value.server_options.map(item => item.trim()).filter(Boolean))
)
const loginMethodFilterOptions = computed(() =>
uniqueOptions(
publishOptions.value.login_method_options
.map((item) => item.trim())
.filter(Boolean)
)
);
uniqueOptions(publishOptions.value.login_method_options.map(item => item.trim()).filter(Boolean))
)
const filterSections = computed<FilterSection[]>(() => [
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", maxPlaceholder: "最高价" },
{ key: "coin", title: "哈夫币数量", type: "range", unit: "M", minPlaceholder: "最低", maxPlaceholder: "最高" },
{ key: "server", title: "区服", type: "chips", options: serverFilterOptions.value },
{ key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value },
{ key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options },
{ key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options },
{ key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options },
...publishOptions.value.quantity_items.map((item) => ({
{
key: 'price',
title: '价格区间',
type: 'range',
unit: '元',
minPlaceholder: '最低价',
maxPlaceholder: '最高价',
},
{
key: 'coin',
title: '哈夫币数量',
type: 'range',
unit: 'M',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
{ key: 'login', title: '上号方式', type: 'chips', options: loginMethodFilterOptions.value },
{
key: 'insurance',
title: '保险',
type: 'chips',
options: publishOptions.value.insurance_options,
},
{ key: 'stamina', title: '体力', type: 'chips', options: publishOptions.value.level_options },
{ key: 'load', title: '负重', type: 'chips', options: publishOptions.value.level_options },
...publishOptions.value.quantity_items.map(item => ({
key: `resource_${item.key}`,
title: item.label,
type: "range" as const,
type: 'range' as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
minPlaceholder: '最低',
maxPlaceholder: '最高',
})),
...publishOptions.value.skin_groups.map((group) => ({
...publishOptions.value.skin_groups.map(group => ({
key: group.key,
title: group.title,
type: "chips" as const,
type: 'chips' as const,
options: group.options,
})),
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" },
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options },
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" },
]);
{
key: 'secretKd',
title: '绝密KD',
type: 'range',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
{ key: 'rank', title: '段位', type: 'chips', options: publishOptions.value.rank_options },
{
key: 'deposit',
title: '押金',
type: 'range',
unit: '元',
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
])
const activeFilterCount = computed(() => {
const chipCount = Object.values(selectedFilters.value).reduce(
(sum, values) => sum + values.length,
0
);
)
const rangeCount = Object.values(rangeFilters.value).filter(
(range) => range.min || range.max
).length;
return chipCount + rangeCount;
});
range => range.min || range.max
).length
return chipCount + rangeCount
})
const displayListings = computed(() => {
return listings.value;
});
return listings.value
})
onMounted(() => {
loadListings();
loadHomeConfig();
window.addEventListener("scroll", handleWindowScroll, { passive: true });
});
loadListings()
loadHomeConfig()
window.addEventListener('scroll', handleWindowScroll, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll);
});
window.removeEventListener('scroll', handleWindowScroll)
})
watch(
() => listingQuerySignature(),
() => {
loadListings(true);
loadListings(true)
}
);
)
async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
const requestSeq = ++listingRequestSeq;
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return
const requestSeq = ++listingRequestSeq
if (reset) {
loading.value = true;
currentPage.value = 1;
hasMoreListings.value = true;
loading.value = true
currentPage.value = 1
hasMoreListings.value = true
}
loadingMore.value = true;
loadFailed.value = false;
loadingMore.value = true
loadFailed.value = false
try {
const page = await fetchListingsPage(buildListingQuery(currentPage.value));
if (requestSeq !== listingRequestSeq) return;
listings.value = reset ? page.items : [...listings.value, ...page.items];
totalListings.value = page.total;
hasMoreListings.value = listings.value.length < page.total;
currentPage.value = page.page + 1;
requestAnimationFrame(handleWindowScroll);
const page = await fetchListingsPage(buildListingQuery(currentPage.value))
if (requestSeq !== listingRequestSeq) return
listings.value = reset ? page.items : [...listings.value, ...page.items]
totalListings.value = page.total
hasMoreListings.value = listings.value.length < page.total
currentPage.value = page.page + 1
requestAnimationFrame(handleWindowScroll)
} catch {
if (reset) {
listings.value = [];
totalListings.value = 0;
hasMoreListings.value = false;
loadFailed.value = true;
listings.value = []
totalListings.value = 0
hasMoreListings.value = false
loadFailed.value = true
}
} finally {
if (requestSeq === listingRequestSeq) {
loading.value = false;
loadingMore.value = false;
loading.value = false
loadingMore.value = false
}
}
}
async function loadHomeConfig() {
try {
const config = await fetchMobileHomeConfig();
announcements.value = config.announcements;
bannerSlides.value = config.banners;
publishOptions.value = config.publish_options;
const config = await fetchMobileHomeConfig()
announcements.value = config.announcements
bannerSlides.value = config.banners
publishOptions.value = config.publish_options
} catch {
announcements.value = defaultHomeAnnouncements;
bannerSlides.value = defaultHomeBanners;
publishOptions.value = emptyListingPublishOptions;
announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners
publishOptions.value = emptyListingPublishOptions
}
}
async function onRefresh() {
refreshing.value = true;
refreshing.value = true
try {
const [, nextHomeConfig] = await Promise.all([
loadListings(true),
fetchMobileHomeConfig(),
]);
announcements.value = nextHomeConfig.announcements;
bannerSlides.value = nextHomeConfig.banners;
publishOptions.value = nextHomeConfig.publish_options;
showToast({ message: "刷新成功", icon: "passed" });
const [, nextHomeConfig] = await Promise.all([loadListings(true), fetchMobileHomeConfig()])
announcements.value = nextHomeConfig.announcements
bannerSlides.value = nextHomeConfig.banners
publishOptions.value = nextHomeConfig.publish_options
showToast({ message: '刷新成功', icon: 'passed' })
} catch {
// 静默处理
} finally {
refreshing.value = false;
refreshing.value = false
}
}
function openFilters() {
sortOpen.value = false;
filterOpen.value = true;
sortOpen.value = false
filterOpen.value = true
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
sortOpen.value = !sortOpen.value
}
function selectSort(sortKey: string) {
activeSort.value = sortKey;
sortOpen.value = false;
activeSort.value = sortKey
sortOpen.value = false
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
selectedFilters.value = {}
rangeFilters.value = {}
searchValue.value = ''
}
function buildListingQuery(page: number): PublicListingQuery {
@@ -267,79 +288,78 @@ function buildListingQuery(page: number): PublicListingQuery {
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
};
const skinGroups: string[] = [];
const skinNames: string[] = [];
}
const skinGroups: string[] = []
const skinNames: string[] = []
for (const [key, values] of Object.entries(selectedFilters.value)) {
const value = values.filter(Boolean).join(",");
if (!value) continue;
if (key === "server") query.server = value;
else if (key === "login") query.login_method = value;
else if (key === "insurance") query.insurance = value;
else if (key === "stamina") query.stamina = value;
else if (key === "load") query.load = value;
else if (key === "rank") query.rank = value;
const value = values.filter(Boolean).join(',')
if (!value) continue
if (key === 'server') query.server = value
else if (key === 'login') query.login_method = value
else if (key === 'insurance') query.insurance = value
else if (key === 'stamina') query.stamina = value
else if (key === 'load') query.load = value
else if (key === 'rank') query.rank = value
else if (isSkinGroupKey(key)) {
skinGroups.push(key);
skinNames.push(...values);
skinGroups.push(key)
skinNames.push(...values)
}
}
if (skinGroups.length) query.skin_group = skinGroups.join(",");
if (skinNames.length) query.skin_name = skinNames.join(",");
if (skinGroups.length) query.skin_group = skinGroups.join(',')
if (skinNames.length) query.skin_name = skinNames.join(',')
for (const [key, range] of Object.entries(rangeFilters.value)) {
if (!range.min && !range.max) continue;
const min = parseOptionalNumber(range.min);
const max = parseOptionalNumber(range.max);
if (key === "price") {
query.min_price = min;
query.max_price = max;
} else if (key === "coin") {
query.min_coin = min;
query.max_coin = max;
} else if (key === "secretKd") {
query.min_secret_kd = min;
query.max_secret_kd = max;
} else if (key === "deposit") {
query.min_deposit = min;
query.max_deposit = max;
} else if (key.startsWith("resource_")) {
const resourceKey = key.replace("resource_", "");
query[`resource_${resourceKey}_min`] = min;
query[`resource_${resourceKey}_max`] = max;
if (!range.min && !range.max) continue
const min = parseOptionalNumber(range.min)
const max = parseOptionalNumber(range.max)
if (key === 'price') {
query.min_price = min
query.max_price = max
} else if (key === 'coin') {
query.min_coin = min
query.max_coin = max
} else if (key === 'secretKd') {
query.min_secret_kd = min
query.max_secret_kd = max
} else if (key === 'deposit') {
query.min_deposit = min
query.max_deposit = max
} else if (key.startsWith('resource_')) {
const resourceKey = key.replace('resource_', '')
query[`resource_${resourceKey}_min`] = min
query[`resource_${resourceKey}_max`] = max
}
}
return query;
return query
}
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1));
return JSON.stringify(buildListingQuery(1))
}
function parseOptionalNumber(value: string) {
if (value === "") return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
if (value === '') return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
function handleWindowScroll() {
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return;
loadListings(false);
if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - 360) return
loadListings(false)
}
function isSkinGroupKey(key: string) {
return publishOptions.value.skin_groups.some((group) => group.key === key);
return publishOptions.value.skin_groups.some(group => group.key === key)
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
const unit = price.split('/')[1]?.trim()
return unit || undefined
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
return [...new Set(values.map(item => item.trim()).filter(Boolean))]
}
</script>
<template>
@@ -360,8 +380,13 @@ function uniqueOptions(values: string[]) {
placeholder="搜区服 / 段位"
class="home-search"
/>
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
{{ supportLoading ? "接入中" : "客服" }}
<button
class="mobile-service"
type="button"
:disabled="supportLoading"
@click="handleSupportClick"
>
{{ supportLoading ? '接入中' : '客服' }}
</button>
</div>
@@ -388,10 +413,7 @@ function uniqueOptions(values: string[]) {
<section class="mobile-content">
<!-- Banner 轮播 -->
<van-swipe class="banner-swipe" :autoplay="3600" lazy-render>
<van-swipe-item
v-for="slide in bannerSlides"
:key="slide.title || slide.image_url"
>
<van-swipe-item v-for="slide in bannerSlides" :key="slide.title || slide.image_url">
<div
class="mobile-banner"
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
@@ -432,11 +454,7 @@ function uniqueOptions(values: string[]) {
@click="selectSort(option.key)"
>
<span>{{ option.label }}</span>
<van-icon
v-if="activeSort === option.key"
name="success"
:size="18"
/>
<van-icon v-if="activeSort === option.key" name="success" :size="18" />
</button>
</div>
<div class="result-count">
@@ -460,9 +478,7 @@ function uniqueOptions(values: string[]) {
image="search"
description="没有符合条件的账号"
>
<van-button size="small" type="primary" @click="clearFilters">
重置条件
</van-button>
<van-button size="small" type="primary" @click="clearFilters"> 重置条件 </van-button>
</van-empty>
<!-- 列表卡片全宽上下布局 -->
@@ -510,10 +526,7 @@ function uniqueOptions(values: string[]) {
</div>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
>
<span v-for="chip in getListingChips(item)" :key="`${item.id}-${chip.label}`">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showDialog } from 'vant'
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 { formatMoney } from "@/shared/utils/money";
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 { formatMoney } from '@/shared/utils/money'
import {
assetRegions,
formatHafCoinM,
@@ -23,241 +27,240 @@ import {
getLoginMethod,
getServerRegion,
readAssetString,
} from "@/utils/listingDisplay";
} 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 listing = ref<Listing | null>(null);
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 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 listing = ref<Listing | null>(null)
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,
);
renterAgreementChecked.value
)
onMounted(async () => {
loading.value = true;
loading.value = true
try {
listing.value = await fetchListing(String(route.params.id));
listing.value = await fetchListing(String(route.params.id))
} catch {
showToast({ message: "加载失败", icon: "warning-o" });
showToast({ message: '加载失败', icon: 'warning-o' })
} finally {
loading.value = false;
loading.value = false
}
});
})
const orderTotal = computed(() => {
if (!listing.value) return "0.0";
return formatMoney(getListingDisplayPrice(listing.value));
});
if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value))
})
const orderPriceBreakdown = computed(() => {
if (!listing.value) {
return {
rent: 0,
consumable: 0,
};
}
}
return {
rent: getListingRentPrice(listing.value),
consumable: getListingConsumablePrice(listing.value),
};
});
}
})
const detailMetrics = computed(() => {
if (!listing.value) return [];
const dailyLoss = getDailyLoss(listing.value);
if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value)
return [
{ label: "纯币", value: formatHafCoinM(getCoinWan(listing.value)), tone: "coin" },
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: "日损耗",
value: dailyLoss ? `${dailyLoss}/天` : "--",
tone: "coin",
label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: "价格", value: `¥${getListingDisplayPrice(listing.value)}`, tone: "price" },
{ label: "押金", value: `¥${listing.value.deposit_amount}`, tone: "" },
];
});
{ label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
]
})
const detailScreenshots = computed(() => {
if (!listing.value) return [];
const groupedScreenshots = readGroupedScreenshots(listing.value);
if (groupedScreenshots.length) return groupedScreenshots;
const labels = ["纯币截图", "游戏ID截图", "总资产截图", "腾讯安全中心截图", "皮肤截图"];
if (!listing.value) return []
const groupedScreenshots = readGroupedScreenshots(listing.value)
if (groupedScreenshots.length) return groupedScreenshots
const labels = ['纯币截图', '游戏ID截图', '总资产截图', '腾讯安全中心截图', '皮肤截图']
return (listing.value.screenshot_urls || []).map((url, index) => ({
label: labels[index] || `账号截图${index + 1}`,
url,
}));
});
}))
})
function readGroupedScreenshots(item: Listing) {
const groups = item.asset_summary?.screenshot_groups;
if (typeof groups !== "object" || groups === null) return [];
const groups = item.asset_summary?.screenshot_groups
if (typeof groups !== 'object' || groups === null) return []
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 validUrls = urls.filter((url): url is string => typeof url === "string" && Boolean(url));
{ 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 validUrls = urls.filter((url): url is string => typeof url === 'string' && Boolean(url))
return validUrls.map((url, index) => ({
label: validUrls.length > 1 ? `${slot.label}${index + 1}` : slot.label,
url,
}));
});
}))
})
}
const detailSkinGroups = computed(() => {
if (!listing.value) return [];
const groups = listing.value.asset_summary?.skin_groups;
if (typeof groups !== "object" || groups === null) return [];
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: "武器皮肤",
};
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")
? value.filter((skin): skin is string => typeof skin === 'string')
: [],
}))
.filter((group) => group.options.length);
});
.filter(group => group.options.length)
})
/* 下单 */
async function handleCreateOrder() {
if (!listing.value) return;
if (!listing.value) return
if (!session.token) {
showDialog({
title: "请先登录",
message: "下单需要登录账号,是否前往登录?",
confirmButtonText: "去登录",
cancelButtonText: "取消",
title: '请先登录',
message: '下单需要登录账号,是否前往登录?',
confirmButtonText: '去登录',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/login", query: { redirect: route.fullPath } });
});
return;
router.push({ path: '/m/login', query: { redirect: route.fullPath } })
})
return
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
try {
await session.loadMe();
await session.loadMe()
} catch {
// 401 会由全局拦截器处理。
}
}
if (session.realnameStatus !== "verified") {
if (session.realnameStatus !== 'verified') {
showDialog({
title: "请先实名认证",
message: "租号下单前需要完成实名认证。",
confirmButtonText: "去认证",
cancelButtonText: "取消",
title: '请先实名认证',
message: '租号下单前需要完成实名认证。',
confirmButtonText: '去认证',
cancelButtonText: '取消',
showCancelButton: true,
}).then(() => {
router.push({ path: "/m/realname", query: { redirect: route.fullPath } });
});
return;
router.push({ path: '/m/realname', query: { redirect: route.fullPath } })
})
return
}
await openAgreementBeforeOrder();
await openAgreementBeforeOrder()
}
async function openAgreementBeforeOrder() {
agreementsLoading.value = true;
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");
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) {
showToast({ message: readError(error, "协议加载失败"), icon: "cross" });
showToast({ message: readError(error, '协议加载失败'), icon: 'cross' })
} finally {
agreementsLoading.value = false;
agreementsLoading.value = false
}
}
async function handleConfirmAgreementAndCreateOrder() {
if (!canCreateOrderAfterAgreement.value) {
showToast("请先阅读并勾选两份协议");
return;
showToast('请先阅读并勾选两份协议')
return
}
agreementVisible.value = false;
await submitOrder();
agreementVisible.value = false
await submitOrder()
}
async function submitOrder() {
if (!listing.value) return;
ordering.value = true;
if (!listing.value) return
ordering.value = true
try {
await createOrder(listing.value.id);
showToast({ message: "订单已创建,请完成支付", icon: "passed" });
await router.push(`/m/orders`);
await createOrder(listing.value.id)
showToast({ message: '订单已创建,请完成支付', icon: 'passed' })
await router.push(`/m/orders`)
} catch (error) {
showToast({ message: readError(error, "下单失败"), icon: "cross" });
showToast({ message: readError(error, '下单失败'), icon: 'cross' })
} finally {
ordering.value = false;
ordering.value = false
}
}
function handleAgreementScroll(type: "virtual" | "renter") {
updateAgreementReadState(type);
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;
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;
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;
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback;
return fallback
}
/** 判断当前底部导航是否激活 */
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
return route.path.startsWith(path);
if (path === '/m') return route.path === '/m'
return route.path.startsWith(path)
}
</script>
@@ -280,9 +283,7 @@ function isNavActive(path: string) {
<!-- 防骗提示 -->
<div class="fraud-tip">
<van-icon name="shield-o" :size="14" color="#ff9800" />
<span
>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span
>
<span>防骗提示下单后请按平台交接流程确认收号与归还不要私下交易</span>
</div>
<!-- 封面图 -->
@@ -299,10 +300,7 @@ function isNavActive(path: string) {
<span>暂无截图</span>
</div>
<!-- 截图指示器 -->
<div
v-if="detailScreenshots.length > 1"
class="cover-count"
>
<div v-if="detailScreenshots.length > 1" class="cover-count">
{{ detailScreenshots.length }}
</div>
</div>
@@ -310,15 +308,11 @@ function isNavActive(path: string) {
<!-- 标题信息 -->
<div class="info-card">
<div class="info-tag-row">
<van-tag plain type="primary" size="medium">{{
getServerRegion(listing)
}}</van-tag>
<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="listing.rank_level" plain size="medium">{{ listing.rank_level }}</van-tag>
</div>
<h2 class="detail-title">{{ getListingTitle(listing) }}</h2>
<p class="detail-desc">{{ getListingSubtitle(listing) }}</p>
@@ -336,10 +330,7 @@ function isNavActive(path: string) {
<div class="info-card">
<h3 class="card-subtitle">账号资料</h3>
<div class="detail-chip-row">
<span
v-for="chip in getListingChips(listing)"
:key="chip.label"
>
<span v-for="chip in getListingChips(listing)" :key="chip.label">
{{ chip.label }}:{{ chip.value }}
</span>
</div>
@@ -349,11 +340,11 @@ function isNavActive(path: string) {
</div>
<div class="info-line">
<span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join("、") || "--" }}</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>
<span class="info-text">{{ readAssetString(listing, 'ban_record') }}</span>
</div>
</div>
@@ -368,9 +359,9 @@ function isNavActive(path: string) {
<span>{{ resource.label }}</span>
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || "--" }}</b>
<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-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
</div>
@@ -427,11 +418,18 @@ function isNavActive(path: string) {
loading-text="下单中..."
@click="handleCreateOrder"
>
{{ listing.in_transaction ? "交易中" : "立即下单" }}
{{ listing.in_transaction ? '交易中' : '立即下单' }}
</van-button>
</div>
<van-popup v-model:show="agreementVisible" round closeable position="bottom" lock-scroll class="agreement-popup">
<van-popup
v-model:show="agreementVisible"
round
closeable
position="bottom"
lock-scroll
class="agreement-popup"
>
<div v-if="agreements" class="agreement-popup-body">
<h3>下单协议确认</h3>
<p>请完整阅读并勾选以下两份协议后继续下单</p>
@@ -444,7 +442,11 @@ function isNavActive(path: string) {
>
{{ agreements.virtual_asset_purchase.content }}
</div>
<van-checkbox v-model="virtualAgreementChecked" :disabled="!virtualAgreementRead" icon-size="18px">
<van-checkbox
v-model="virtualAgreementChecked"
:disabled="!virtualAgreementRead"
icon-size="18px"
>
我已阅读并同意{{ agreements.virtual_asset_purchase.title }}
</van-checkbox>
<span v-if="!virtualAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>
@@ -458,7 +460,11 @@ function isNavActive(path: string) {
>
{{ agreements.renter_agreement.content }}
</div>
<van-checkbox v-model="renterAgreementChecked" :disabled="!renterAgreementRead" icon-size="18px">
<van-checkbox
v-model="renterAgreementChecked"
:disabled="!renterAgreementRead"
icon-size="18px"
>
我已阅读并同意{{ agreements.renter_agreement.title }}
</van-checkbox>
<span v-if="!renterAgreementRead" class="agreement-read-hint">请下拉阅读至底部</span>