546 lines
17 KiB
Vue
546 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { RouterLink, useRoute } from "vue-router";
|
||
import { showToast } from "vant";
|
||
|
||
import {
|
||
emptyListingPublishOptions,
|
||
type ListingPublishOptions,
|
||
} from "@/api/listingOptions";
|
||
import { fetchListings, type Listing } from "@/api/listings";
|
||
import {
|
||
defaultHomeAnnouncements,
|
||
defaultHomeBanners,
|
||
fetchMobileHomeConfig,
|
||
type HomeBannerSlide,
|
||
} from "@/api/homeConfig";
|
||
import MobileHomeFilterSheet, {
|
||
type FilterSection,
|
||
} from "./MobileHomeFilterSheet.vue";
|
||
import {
|
||
assetRegions,
|
||
formatHafCoinM,
|
||
getCoinM,
|
||
getCoinWan,
|
||
getListingChips,
|
||
getListingDisplayPrice,
|
||
getListingSubtitle,
|
||
getListingTitle,
|
||
getLoginMethod,
|
||
getResourceQuantity,
|
||
getServerRegion,
|
||
getSkinGroup,
|
||
hasAcceleratedSaleRatio,
|
||
hasGiftResources,
|
||
readAssetNumber,
|
||
readAssetString,
|
||
} from "@/utils/listingDisplay";
|
||
|
||
const route = useRoute();
|
||
const loading = ref(false);
|
||
const loadFailed = ref(false);
|
||
const listings = ref<Listing[]>([]);
|
||
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 sortOptions = [
|
||
{ key: "comprehensive", label: "综合排序" },
|
||
{ key: "published", label: "发布时间" },
|
||
{ 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 ||
|
||
"综合排序"
|
||
);
|
||
|
||
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(() => {
|
||
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 isNavActive(path: string) {
|
||
if (path === "/m") return route.path === "/m";
|
||
return route.path.startsWith(path);
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadListings();
|
||
loadHomeConfig();
|
||
});
|
||
|
||
async function loadListings() {
|
||
loading.value = true;
|
||
loadFailed.value = false;
|
||
try {
|
||
listings.value = await fetchListings();
|
||
} catch {
|
||
loadFailed.value = true;
|
||
} finally {
|
||
loading.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 [nextListings, nextHomeConfig] = await Promise.all([
|
||
fetchListings(),
|
||
fetchMobileHomeConfig(),
|
||
]);
|
||
listings.value = nextListings;
|
||
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 sortListings(items: Listing[]) {
|
||
const sorted = [...items];
|
||
if (activeSort.value === "comprehensive") {
|
||
return sorted;
|
||
}
|
||
if (activeSort.value === "priceAsc") {
|
||
return sorted.sort(
|
||
(a, b) => getListingDisplayPrice(a) - getListingDisplayPrice(b)
|
||
);
|
||
}
|
||
if (activeSort.value === "priceDesc") {
|
||
return sorted.sort(
|
||
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(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;
|
||
});
|
||
}
|
||
|
||
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;
|
||
});
|
||
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_", ""));
|
||
}
|
||
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;
|
||
}
|
||
|
||
function searchText(item: Listing) {
|
||
return [
|
||
item.title,
|
||
item.description,
|
||
getServerRegion(item),
|
||
getLoginMethod(item),
|
||
item.rank_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(" ")
|
||
),
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
}
|
||
|
||
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">客服</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>{{ displayListings.length }}</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)"
|
||
/>
|
||
<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>
|
||
</section>
|
||
</van-pull-refresh>
|
||
|
||
<MobileHomeFilterSheet
|
||
v-model:show="filterOpen"
|
||
v-model:selected-filters="selectedFilters"
|
||
v-model:range-filters="rangeFilters"
|
||
:sections="filterSections"
|
||
:range-presets="rangePresets"
|
||
@reset="clearFilters"
|
||
/>
|
||
|
||
<!-- ========== 底部导航:原生App风格,手写,不用 van-tabbar ========== -->
|
||
<nav class="bottom-nav">
|
||
<RouterLink
|
||
to="/m"
|
||
class="nav-item"
|
||
:class="{ active: isNavActive('/m') }"
|
||
>
|
||
<van-icon name="home-o" :size="22" />
|
||
<span>首页</span>
|
||
</RouterLink>
|
||
<RouterLink
|
||
to="/m/messages"
|
||
class="nav-item"
|
||
:class="{ active: isNavActive('/m/messages') }"
|
||
>
|
||
<van-icon name="chat-o" :size="22" />
|
||
<span>消息</span>
|
||
</RouterLink>
|
||
<RouterLink to="/m/seller/listings/create" class="nav-item nav-publish">
|
||
<div class="publish-pill">+</div>
|
||
<span>发布</span>
|
||
</RouterLink>
|
||
<RouterLink
|
||
to="/m/orders"
|
||
class="nav-item"
|
||
:class="{ active: isNavActive('/m/orders') }"
|
||
>
|
||
<van-icon name="orders-o" :size="22" />
|
||
<span>订单</span>
|
||
</RouterLink>
|
||
<RouterLink
|
||
to="/m/profile"
|
||
class="nav-item"
|
||
:class="{ active: isNavActive('/m/profile') }"
|
||
>
|
||
<van-icon name="manager-o" :size="22" />
|
||
<span>我的</span>
|
||
</RouterLink>
|
||
</nav>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped src="./MobileHomeView.css"></style>
|