优化筛选

This commit is contained in:
yml
2026-06-02 15:39:39 +08:00
parent 3ea0c3db7d
commit 639434f8db
3 changed files with 463 additions and 195 deletions
+275 -70
View File
@@ -6,12 +6,18 @@ import {
Copy,
Filter,
LoaderCircle,
RefreshCw,
Search,
ShieldCheck,
Sparkles,
} from "@lucide/vue";
import { fetchListingsPage, fetchMobileHomeConfig, type HomeBannerSlide, type Listing, type PublicListingQuery } from "./api";
import {
fetchListingsPage,
fetchMobileHomeConfig,
type HomeBannerSlide,
type Listing,
type ListingPublishOptions,
type PublicListingQuery,
} from "./api";
import {
formatCoin,
formatListingCode,
@@ -28,34 +34,61 @@ const loadError = ref("");
const listings = ref<Listing[]>([]);
const total = ref(0);
const page = ref(1);
const zoneCounts = ref<Record<string, number>>({});
const announcements = ref<string[]>([]);
const banners = ref<HomeBannerSlide[]>([]);
const publishOptions = ref<ListingPublishOptions>({
server_options: [],
login_method_options: [],
rank_options: [],
insurance_options: [],
level_options: [],
skin_groups: [],
});
const copiedCode = ref("");
const activeSort = ref("recommended");
const activeZone = ref("all");
const filterOpen = ref(false);
const filters = reactive({
keyword: "",
minCoin: "",
maxCoin: "",
minPrice: "",
maxPrice: "",
minKd: "",
maxKd: "",
stamina: [] as string[],
load: [] as string[],
insurance: [] as string[],
skinNames: [] as string[],
});
const sortOptions = [
{ key: "recommended", label: "默认" },
{ key: "priceAsc", label: "价格" },
{ key: "coinDesc", label: "哈夫币" },
{ key: "awmDesc", label: "AWM" },
{ key: "recommended", label: "默认", sortable: false },
{ key: "price", label: "价格", sortable: true },
{ key: "ratio", label: "比例", sortable: true },
{ key: "coin", label: "哈夫币", sortable: true },
];
const zoneOptions = computed(() => [
{ key: "all", label: "全部", count: zoneCount("all") },
{ key: "sale", label: "特惠", count: zoneCount("sale") },
{ key: "highCoin", label: "高币", count: zoneCount("highCoin") },
{ key: "password", label: "账密", count: zoneCount("password") },
{ key: "night", label: "夜间", count: zoneCount("night") },
]);
const fallbackLevels = ["3级", "4级", "5级", "6级", "7级"];
const fallbackInsurance = ["2*1", "2*2", "2*3", "3*3"];
const fallbackSkins = ["信条", "坠星者", "处刑者", "影锋", "暗星", "电锯", "蚀金玫瑰", "维什戴尔", "水墨云图"];
const levelOptions = computed(() => publishOptions.value.level_options.length ? publishOptions.value.level_options : fallbackLevels);
const insuranceOptions = computed(() =>
publishOptions.value.insurance_options.length ? publishOptions.value.insurance_options : fallbackInsurance
);
const skinSections = computed(() => {
const groups = publishOptions.value.skin_groups.filter((group) =>
["melee", "operatorGold", "operatorRed", "weapon"].includes(group.key)
);
if (groups.length) {
return groups.map((group) => ({
key: group.key,
title: groupTitle(group.key, group.title),
options: group.options,
}));
}
return [{ key: "skin", title: "刀皮", options: fallbackSkins }];
});
const topBanner = computed(() => banners.value[0] || {
eyebrow: "三角洲行动账号专区",
@@ -66,13 +99,40 @@ const topBanner = computed(() => banners.value[0] || {
});
const hasMore = computed(() => listings.value.length < total.value);
const displayListings = computed(() => {
const items = [...listings.value];
if (activeSort.value === "ratioAsc") {
return items.sort((a, b) => ratioValue(a) - ratioValue(b));
}
if (activeSort.value === "ratioDesc") {
return items.sort((a, b) => ratioValue(b) - ratioValue(a));
}
if (activeSort.value === "coinAsc") {
return items.sort((a, b) => a.haf_coin_amount - b.haf_coin_amount);
}
return items;
});
const hasAdvancedFilters = computed(() => {
return Boolean(
filters.minCoin ||
filters.maxCoin ||
filters.minPrice ||
filters.maxPrice ||
filters.minKd ||
filters.maxKd ||
filters.stamina.length ||
filters.load.length ||
filters.insurance.length ||
filters.skinNames.length
);
});
onMounted(() => {
loadHome();
});
watch(
() => [activeSort.value, activeZone.value],
() => activeSort.value,
() => loadListings(true)
);
@@ -82,6 +142,7 @@ async function loadHome() {
const [home] = await Promise.all([fetchMobileHomeConfig(), loadListings(true)]);
announcements.value = home.announcements;
banners.value = home.banners;
publishOptions.value = home.publish_options;
} catch (error) {
loadError.value = error instanceof Error ? error.message : "加载失败";
} finally {
@@ -99,7 +160,6 @@ async function loadListings(reset = false) {
listings.value = reset ? result.items : [...listings.value, ...result.items];
total.value = result.total;
page.value = result.page + 1;
zoneCounts.value = result.zone_counts;
} catch (error) {
loadError.value = error instanceof Error ? error.message : "加载失败";
if (reset) listings.value = [];
@@ -113,12 +173,17 @@ function buildQuery(nextPage: number): PublicListingQuery {
page: nextPage,
page_size: pageSize,
keyword: filters.keyword.trim(),
sort: activeSort.value,
zone: activeZone.value,
sort: backendSortKey(),
min_coin: toNumber(filters.minCoin),
max_coin: toNumber(filters.maxCoin),
min_price: toNumber(filters.minPrice),
max_price: toNumber(filters.maxPrice),
min_secret_kd: toNumber(filters.minKd),
max_secret_kd: toNumber(filters.maxKd),
stamina: filters.stamina.join(","),
load: filters.load.join(","),
insurance: filters.insurance.join(","),
skin_name: filters.skinNames.join(","),
};
}
@@ -132,11 +197,71 @@ function resetFilters() {
filters.maxCoin = "";
filters.minPrice = "";
filters.maxPrice = "";
filters.minKd = "";
filters.maxKd = "";
filters.stamina = [];
filters.load = [];
filters.insurance = [];
filters.skinNames = [];
activeSort.value = "recommended";
activeZone.value = "all";
loadListings(true);
}
function confirmFilters() {
filterOpen.value = false;
loadListings(true);
}
function setQuickSort(key: string) {
if (key === "recommended") {
activeSort.value = "recommended";
return;
}
if (key === "price") {
activeSort.value = activeSort.value === "priceAsc" ? "priceDesc" : "priceAsc";
return;
}
if (key === "ratio") {
activeSort.value = activeSort.value === "ratioDesc" ? "ratioAsc" : "ratioDesc";
return;
}
if (key === "coin") {
activeSort.value = activeSort.value === "coinDesc" ? "coinAsc" : "coinDesc";
}
}
function isQuickSortActive(key: string) {
if (key === "recommended") return activeSort.value === "recommended";
return activeSort.value.startsWith(key);
}
function sortMark(key: string) {
if (key === "price") return activeSort.value === "priceAsc" ? "▲" : activeSort.value === "priceDesc" ? "▼" : "↕";
if (key === "ratio") return activeSort.value === "ratioAsc" ? "▲" : activeSort.value === "ratioDesc" ? "▼" : "↕";
if (key === "coin") return activeSort.value === "coinAsc" ? "▲" : activeSort.value === "coinDesc" ? "▼" : "↕";
return "";
}
function backendSortKey() {
if (activeSort.value === "priceAsc" || activeSort.value === "priceDesc" || activeSort.value === "coinDesc") {
return activeSort.value;
}
return "recommended";
}
function toggleListValue(list: string[], value: string) {
const index = list.indexOf(value);
if (index >= 0) {
list.splice(index, 1);
return;
}
list.push(value);
}
function isSelected(list: string[], value: string) {
return list.includes(value);
}
async function copyListing(item: Listing) {
const text = `${formatListingCode(item)} 纯币 ${formatCoin(item)}${item.price} 比例 ${formatRatio(item)}`;
await navigator.clipboard?.writeText(text);
@@ -146,15 +271,32 @@ async function copyListing(item: Listing) {
}, 1600);
}
function zoneCount(key: string) {
return Number(zoneCounts.value[key] || 0);
}
function toNumber(value: string) {
if (!value.trim()) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function ratioValue(item: Listing) {
const configuredRatio = readAssetNumber(item, "publish_ratio");
if (configuredRatio > 0) return configuredRatio;
if (!item.price) return 0;
return Number(item.haf_coin_amount || 0) / 10000 / item.price;
}
function groupTitle(key: string, title: string) {
if (key === "melee") return "刀皮";
if (key === "operatorGold") return "干员金皮";
if (key === "operatorRed") return "干员红皮";
if (key === "weapon") return "枪皮";
return title;
}
function insuranceLabel(value: string) {
const [rows, cols] = value.split("*").map((item) => Number(item));
if (rows > 0 && cols > 0) return `${rows * cols}`;
return value;
}
</script>
<template>
@@ -193,64 +335,127 @@ function toNumber(value: string) {
<span>{{ announcements[0] }}</span>
</div>
<section class="filters">
<div class="segmented" aria-label="专区筛选">
<button
v-for="zone in zoneOptions"
:key="zone.key"
:class="{ active: activeZone === zone.key }"
type="button"
@click="activeZone = zone.key"
>
{{ zone.label }} <span>{{ zone.count }}</span>
</button>
</div>
<div class="sorts">
<section class="quick-filter">
<div class="sort-line">
<button
v-for="sort in sortOptions"
:key="sort.key"
:class="{ active: activeSort === sort.key }"
:class="{ active: isQuickSortActive(sort.key) }"
type="button"
@click="activeSort = sort.key"
@click="setQuickSort(sort.key)"
>
<ArrowDownUp v-if="sort.key !== 'recommended'" :size="13" />
{{ sort.label }}
<span v-if="sort.sortable" class="sort-mark">{{ sortMark(sort.key) }}</span>
</button>
</div>
<div class="rangebar">
<label>
<span>哈夫币</span>
<input v-model="filters.minCoin" inputmode="numeric" placeholder="最低M" @keyup.enter="submitSearch" />
<input v-model="filters.maxCoin" inputmode="numeric" placeholder="最高M" @keyup.enter="submitSearch" />
</label>
<label>
<span>价格</span>
<input v-model="filters.minPrice" inputmode="numeric" placeholder="最低¥" @keyup.enter="submitSearch" />
<input v-model="filters.maxPrice" inputmode="numeric" placeholder="最高¥" @keyup.enter="submitSearch" />
</label>
<button class="icon-action" type="button" title="应用筛选" @click="submitSearch">
<Filter :size="17" />
</button>
<button class="icon-action" type="button" title="重置" @click="resetFilters">
<RefreshCw :size="17" />
<button :class="{ active: filterOpen || hasAdvancedFilters }" type="button" @click="filterOpen = !filterOpen">
筛选 <Filter :size="17" />
</button>
</div>
</section>
<section class="summary">
<div>
<strong>{{ total }}</strong>
<span>当前可租账号</span>
<section v-if="filterOpen" class="filter-panel">
<div class="filter-head">
<strong>筛选条件</strong>
<button type="button" @click="resetFilters">重置</button>
</div>
<div>
<strong>{{ zoneCount("highCoin") }}</strong>
<span>100M 以上</span>
<div class="filter-body">
<label class="filter-field filter-field-wide">
<span>账号编号</span>
<input v-model="filters.keyword" placeholder="输入编号搜索" @keyup.enter="confirmFilters" />
</label>
<div class="filter-group">
<strong>体力等级</strong>
<div class="filter-chips">
<button
v-for="option in levelOptions"
:key="`stamina-${option}`"
:class="{ active: isSelected(filters.stamina, option) }"
type="button"
@click="toggleListValue(filters.stamina, option)"
>
{{ option }}
</button>
</div>
</div>
<div class="filter-group">
<strong>负重等级</strong>
<div class="filter-chips">
<button
v-for="option in levelOptions"
:key="`load-${option}`"
:class="{ active: isSelected(filters.load, option) }"
type="button"
@click="toggleListValue(filters.load, option)"
>
{{ option }}
</button>
</div>
</div>
<div class="filter-group">
<strong>保险格数</strong>
<div class="filter-chips">
<button
v-for="option in insuranceOptions"
:key="`insurance-${option}`"
:class="{ active: isSelected(filters.insurance, option) }"
type="button"
@click="toggleListValue(filters.insurance, option)"
>
{{ insuranceLabel(option) }}
</button>
</div>
</div>
<div v-for="section in skinSections" :key="section.key" class="filter-group filter-group-wide">
<strong>{{ section.title }}</strong>
<div class="filter-chips">
<button
v-for="option in section.options"
:key="`${section.key}-${option}`"
:class="{ active: isSelected(filters.skinNames, option) }"
type="button"
@click="toggleListValue(filters.skinNames, option)"
>
{{ option }}
</button>
</div>
</div>
<div class="filter-range filter-group-wide">
<strong>哈夫币范围(M)</strong>
<div>
<input v-model="filters.minCoin" inputmode="numeric" placeholder="最小" @keyup.enter="confirmFilters" />
<span>-</span>
<input v-model="filters.maxCoin" inputmode="numeric" placeholder="最大" @keyup.enter="confirmFilters" />
</div>
</div>
<div class="filter-range filter-group-wide">
<strong>价格范围</strong>
<div>
<input v-model="filters.minPrice" inputmode="numeric" placeholder="最低¥" @keyup.enter="confirmFilters" />
<span>-</span>
<input v-model="filters.maxPrice" inputmode="numeric" placeholder="最高¥" @keyup.enter="confirmFilters" />
</div>
</div>
<div class="filter-range filter-group-wide">
<strong>KD 范围</strong>
<div>
<input v-model="filters.minKd" inputmode="decimal" placeholder="最小" @keyup.enter="confirmFilters" />
<span>-</span>
<input v-model="filters.maxKd" inputmode="decimal" placeholder="最大" @keyup.enter="confirmFilters" />
</div>
</div>
</div>
<div>
<strong>{{ zoneCount("sale") }}</strong>
<span>特惠账号</span>
<div class="filter-actions">
<button type="button" @click="filterOpen = false">取消</button>
<button type="button" @click="confirmFilters">确定筛选</button>
</div>
</section>
@@ -265,7 +470,7 @@ function toNumber(value: string) {
</div>
<section v-else class="grid">
<article v-for="item in listings" :key="item.id" class="card">
<article v-for="item in displayListings" :key="item.id" class="card">
<div class="card-head">
<div>
<h2>编号 {{ formatListingCode(item) }}</h2>
+26
View File
@@ -45,10 +45,18 @@ export interface PublicListingQuery {
keyword?: string;
sort?: string;
zone?: string;
login_method?: string;
rank?: string;
insurance?: string;
stamina?: string;
load?: string;
skin_name?: string;
min_coin?: number;
max_coin?: number;
min_price?: number;
max_price?: number;
min_secret_kd?: number;
max_secret_kd?: number;
}
export interface HomeBannerSlide {
@@ -66,6 +74,11 @@ export interface ListingPublishOptions {
rank_options: string[];
insurance_options: string[];
level_options: string[];
skin_groups: Array<{
key: string;
title: string;
options: string[];
}>;
}
export interface MobileHomeConfig {
@@ -121,9 +134,22 @@ function normalizeOptions(value: unknown): ListingPublishOptions {
rank_options: normalizeStringArray(options.rank_options),
insurance_options: normalizeStringArray(options.insurance_options),
level_options: normalizeStringArray(options.level_options),
skin_groups: normalizeSkinGroups(options.skin_groups),
};
}
function normalizeSkinGroups(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
.map((item) => ({
key: typeof item.key === "string" ? item.key : "",
title: typeof item.title === "string" ? item.title : "",
options: normalizeStringArray(item.options),
}))
.filter((item) => item.key && item.title);
}
async function request<T>(path: string, query: Record<string, unknown> = {}) {
const url = new URL(`${apiBase}${path}`, window.location.origin);
Object.entries(query).forEach(([key, value]) => {
+162 -125
View File
@@ -196,10 +196,8 @@ button {
}
.notice span,
.segmented,
.sorts,
.rangebar,
.summary,
.quick-filter,
.filter-panel,
.grid {
min-width: 0;
}
@@ -209,114 +207,160 @@ button {
flex: none;
}
.filters {
display: grid;
gap: 10px;
padding: 10px;
.quick-filter {
background: #fff;
margin-bottom: 20px;
min-width: 0;
overflow: hidden;
}
.segmented,
.sorts {
display: flex;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
.sort-line {
min-height: 66px;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
}
.segmented button,
.sorts button {
min-height: 34px;
.sort-line button {
min-height: 66px;
border: 0;
background: transparent;
color: #777b85;
color: #686d77;
display: inline-flex;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 6px;
padding: 0 18px;
gap: 5px;
padding: 0 8px;
font-weight: 700;
font-size: 15px;
}
.sort-line button.active {
color: #ff506d;
}
.sort-mark {
color: #c4c6cc;
font-size: 12px;
}
.filter-panel {
background: #fff;
margin: -8px 0 20px;
border-top: 1px solid #f0f1f4;
border-radius: 0 0 8px 8px;
box-shadow: 0 8px 18px rgba(24, 26, 32, 0.05);
}
.filter-head {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
border-bottom: 1px solid #f0f1f4;
}
.filter-head button {
border: 0;
background: transparent;
color: #ff506d;
font-weight: 700;
}
.segmented button.active,
.sorts button.active {
color: #ff506d;
background: #fff0f3;
}
.segmented span {
color: #b4b7bd;
font-size: 12px;
}
.rangebar {
.filter-body {
display: grid;
grid-template-columns: 1fr 1fr 36px 36px;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 28px;
padding: 16px 10px 20px;
}
.rangebar label {
.filter-field,
.filter-group,
.filter-range {
display: grid;
grid-template-columns: max-content 1fr 1fr;
align-items: center;
gap: 6px;
min-width: 0;
}
.rangebar label span {
color: #757985;
font-size: 13px;
}
.rangebar input {
width: 100%;
min-width: 0;
height: 34px;
border: 1px solid #eceef2;
border-radius: 6px;
padding: 0 8px;
outline: 0;
}
.icon-action {
width: 36px;
height: 34px;
border: 1px solid #eceef2;
border-radius: 6px;
background: #fff;
color: #606571;
display: grid;
place-items: center;
}
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 12px 0;
}
.summary div {
min-width: 0;
min-height: 58px;
border-radius: 8px;
background: #fff;
display: grid;
place-items: center;
align-content: center;
box-shadow: 0 1px 8px rgba(24, 26, 32, 0.04);
}
.summary strong {
font-size: 22px;
.filter-field-wide,
.filter-group-wide {
grid-column: 1 / -1;
}
.filter-field span,
.filter-group strong,
.filter-range strong {
color: #33363d;
font-size: 14px;
}
.filter-field input,
.filter-range input {
width: 100%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 5px;
outline: 0;
padding: 0 12px;
color: #33363d;
}
.summary span {
font-size: 12px;
color: #8e929c;
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-chips button {
min-width: 58px;
min-height: 36px;
border: 1px solid #e5e7eb;
border-radius: 5px;
background: #fff;
color: #555b66;
padding: 0 14px;
}
.filter-chips button.active {
border-color: #ff6f86;
color: #ff506d;
background: #fff2f5;
}
.filter-range > div {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.filter-range span {
color: #9aa0aa;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0 10px 18px;
}
.filter-actions button {
height: 44px;
border: 0;
border-radius: 999px;
font-weight: 700;
}
.filter-actions button:first-child {
background: #f2f2f3;
color: #70757f;
}
.filter-actions button:last-child {
background: #ff6f86;
color: #fff;
}
.grid {
@@ -516,8 +560,8 @@ button {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rangebar {
grid-template-columns: 1fr 1fr;
.filter-body {
grid-template-columns: 1fr;
}
}
@@ -539,49 +583,42 @@ button {
display: none;
}
.summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.segmented,
.sorts {
justify-content: flex-start;
flex-wrap: wrap;
overflow-x: hidden;
max-width: 100%;
scrollbar-width: none;
}
.segmented::-webkit-scrollbar,
.sorts::-webkit-scrollbar {
display: none;
}
.segmented button,
.sorts button {
flex: 0 0 auto;
padding: 0 16px;
}
.hero-copy h1 {
font-size: 22px;
line-height: 1.15;
}
.sort-line {
grid-template-columns: repeat(5, max-content);
overflow-x: auto;
justify-content: space-between;
}
.sort-line button {
min-width: 68px;
padding: 0 10px;
font-size: 14px;
}
.filter-panel {
margin-top: -10px;
}
.filter-body {
gap: 16px;
}
.filter-chips button {
min-width: 58px;
padding: 0 12px;
}
.filter-actions {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
gap: 12px;
}
.rangebar {
grid-template-columns: 1fr;
}
.rangebar label {
grid-template-columns: 52px 1fr 1fr;
}
.icon-action {
width: 100%;
}
}