Files
hfb_sys/frontend/src/features/listings/views/MobileHomeView.vue
T
yml2213 eb6aed7e56 chore: 修复 listings 视图导入路径
- 修复 ListingDetailView, ListingsView 使用 features/listings
- 修复 MobileHomeView, MobileListingDetailView 使用正确路径
- 修复 MobileRealnameView 使用 features/auth

类型错误:72 → 45 (-27)
模块错误继续下降
2026-06-04 10:16:08 +08:00

542 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, 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 {
emptyListingPublishOptions,
type ListingPublishOptions,
} 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";
import {
getListingChips,
getListingDisplayPrice,
getListingSubtitle,
getListingTitle,
getLoginMethod,
getServerRegion,
hasAcceleratedSaleRatio,
hasGiftResources,
} 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 sortOptions = [
{ 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: "" },
],
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: "" },
],
};
const activeSortLabel = computed(
() =>
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;
}
if (supportLoading.value) return;
supportLoading.value = true;
try {
const chat = await ensureSupportChat();
router.push(`/m/chats/${chat.id}`);
} catch {
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
} finally {
supportLoading.value = false;
}
}
const serverFilterOptions = computed(() =>
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)
)
);
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: `resource_${item.key}`,
title: item.label,
type: "range" as const,
unit: parseQuantityUnit(item.price),
minPlaceholder: "最低",
maxPlaceholder: "最高",
})),
...publishOptions.value.skin_groups.map((group) => ({
key: group.key,
title: group.title,
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: "最高" },
]);
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;
});
const displayListings = computed(() => {
return listings.value;
});
onMounted(() => {
loadListings();
loadHomeConfig();
window.addEventListener("scroll", handleWindowScroll, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", handleWindowScroll);
});
watch(
() => listingQuerySignature(),
() => {
loadListings(true);
}
);
async function loadListings(reset = true) {
if ((loadingMore.value && !reset) || (!hasMoreListings.value && !reset)) return;
const requestSeq = ++listingRequestSeq;
if (reset) {
loading.value = true;
currentPage.value = 1;
hasMoreListings.value = true;
}
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);
} catch {
if (reset) {
listings.value = [];
totalListings.value = 0;
hasMoreListings.value = false;
loadFailed.value = true;
}
} finally {
if (requestSeq === listingRequestSeq) {
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;
} catch {
announcements.value = defaultHomeAnnouncements;
bannerSlides.value = defaultHomeBanners;
publishOptions.value = emptyListingPublishOptions;
}
}
async function onRefresh() {
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" });
} catch {
// 静默处理
} finally {
refreshing.value = false;
}
}
function openFilters() {
sortOpen.value = false;
filterOpen.value = true;
}
function toggleSortPanel() {
sortOpen.value = !sortOpen.value;
}
function selectSort(sortKey: string) {
activeSort.value = sortKey;
sortOpen.value = false;
}
function clearFilters() {
selectedFilters.value = {};
rangeFilters.value = {};
searchValue.value = "";
}
function buildListingQuery(page: number): PublicListingQuery {
const query: PublicListingQuery = {
page,
page_size: mobilePageSize,
keyword: searchValue.value.trim(),
sort: activeSort.value,
};
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;
else if (isSkinGroupKey(key)) {
skinGroups.push(key);
skinNames.push(...values);
}
}
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;
}
}
return query;
}
function listingQuerySignature() {
return JSON.stringify(buildListingQuery(1));
}
function parseOptionalNumber(value: string) {
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);
}
function isSkinGroupKey(key: string) {
return publishOptions.value.skin_groups.some((group) => group.key === key);
}
function parseQuantityUnit(price: string) {
const unit = price.split("/")[1]?.trim();
return unit || undefined;
}
function uniqueOptions(values: string[]) {
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
}
</script>
<template>
<main class="mobile-shell">
<!-- ========== Hero 区域顶部搜索与公告 ========== -->
<section class="mobile-hero">
<div class="mobile-topbar">
<div class="mobile-brand">
<span class="mobile-logo"></span>
<div>
<strong>大锤商行</strong>
<small>哈夫币租号</small>
</div>
</div>
<van-search
v-model="searchValue"
shape="round"
placeholder="搜区服 / 段位"
class="home-search"
/>
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
{{ supportLoading ? "接入中" : "客服" }}
</button>
</div>
<!-- 防骗提示卡片不用 van-notice-bar -->
<div class="fraud-tip">
<van-icon name="warning-o" :size="16" color="#b8860b" />
<span class="fraud-dot"></span>
<van-swipe
class="announcement-swipe"
vertical
:autoplay="3200"
:show-indicators="false"
touchable
>
<van-swipe-item v-for="item in announcements" :key="item">
<span>{{ item }}</span>
</van-swipe-item>
</van-swipe>
</div>
</section>
<!-- ========== Content 区域 ========== -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<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"
>
<div
class="mobile-banner"
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
>
<img
v-if="slide.image_url"
class="banner-image"
:src="slide.image_url"
:alt="slide.title || slide.eyebrow || '首页轮播图'"
/>
<div>
<p v-if="slide.eyebrow">{{ slide.eyebrow }}</p>
<h1 v-if="slide.title">{{ slide.title }}</h1>
<span v-if="slide.pill">{{ slide.pill }}</span>
</div>
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
</div>
</van-swipe-item>
</van-swipe>
<div class="list-toolbar">
<button type="button" class="sort-entry" @click="toggleSortPanel">
<span>{{ activeSortLabel }}</span>
<van-icon :name="sortOpen ? 'arrow-up' : 'arrow-down'" :size="14" />
</button>
<button type="button" class="filter-entry" @click="openFilters">
<van-icon name="filter-o" :size="16" />
<span>筛选</span>
<em v-if="activeFilterCount">{{ activeFilterCount }}</em>
</button>
</div>
<div v-if="sortOpen" class="sort-panel">
<button
v-for="option in sortOptions"
:key="option.key"
type="button"
:class="{ active: activeSort === option.key }"
@click="selectSort(option.key)"
>
<span>{{ option.label }}</span>
<van-icon
v-if="activeSort === option.key"
name="success"
:size="18"
/>
</button>
</div>
<div class="result-count">
<strong>{{ totalListings }}</strong>
<span>个可租账号</span>
</div>
<!-- 加载/错误状态 -->
<van-loading v-if="loading" class="state-loading" size="24px" vertical>
正在加载优质账号...
</van-loading>
<van-notice-bar
v-else-if="loadFailed"
left-icon="info-o"
color="#6b7a90"
background="transparent"
text="接口暂不可用,请稍后刷新。"
/>
<van-empty
v-else-if="displayListings.length === 0"
image="search"
description="没有符合条件的账号"
>
<van-button size="small" type="primary" @click="clearFilters">
重置条件
</van-button>
</van-empty>
<!-- 列表卡片全宽上下布局 -->
<div class="mobile-list">
<RouterLink
v-for="item in displayListings"
:key="item.id"
class="mobile-card"
:to="`/m/listings/${item.id}`"
>
<div class="card-cover">
<img
v-if="item.cover_url"
:src="item.cover_url"
:alt="getListingTitle(item)"
loading="lazy"
decoding="async"
/>
<span v-else></span>
<div
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
class="card-cover-labels"
>
<em v-if="hasGiftResources(item)">有赠送</em>
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
</div>
</div>
<div class="card-main">
<div class="card-title-row">
<h2>{{ getListingTitle(item) }}</h2>
</div>
<p class="card-subtitle">{{ getListingSubtitle(item) }}</p>
<div class="card-badges-row">
<span class="trust-badge">押金秒退</span>
<span class="server-badge">{{ getServerRegion(item) }}</span>
<span v-if="getLoginMethod(item)" class="server-badge">
{{ getLoginMethod(item) }}
</span>
</div>
<div class="card-footer">
<div class="price-col">
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span>
</div>
</div>
</div>
<div class="card-chip-row">
<span
v-for="chip in getListingChips(item)"
:key="`${item.id}-${chip.label}`"
>
{{ chip.label }}:{{ chip.value }}
</span>
</div>
</RouterLink>
</div>
<div v-if="!loading && displayListings.length" class="mobile-load-state">
<span v-if="loadingMore">正在加载更多账号...</span>
<span v-else-if="!hasMoreListings">已经到底了</span>
</div>
</section>
</van-pull-refresh>
<MobileHomeFilterSheet
v-model:show="filterOpen"
v-model:selected-filters="selectedFilters"
v-model:range-filters="rangeFilters"
:sections="filterSections"
:range-presets="rangePresets"
/>
<MobileBottomNav />
</main>
</template>
<style scoped src="./MobileHomeView.css"></style>