1390 lines
34 KiB
Vue
1390 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from "vue";
|
||
|
||
import {
|
||
emptyListingPublishOptions,
|
||
type ListingPublishOptions,
|
||
} from "@/api/listingOptions";
|
||
import { fetchListings, type Listing } from "@/api/listings";
|
||
import {
|
||
defaultHomeAnnouncements,
|
||
defaultHomeBanners,
|
||
fetchMobileHomeConfig,
|
||
type HomeBannerSlide,
|
||
} from "@/api/homeConfig";
|
||
import {
|
||
assetRegions,
|
||
formatHafCoinM,
|
||
getCoinM,
|
||
getCoinWan,
|
||
getListingChips,
|
||
getListingDisplayPrice,
|
||
getListingSubtitle,
|
||
getListingTitle,
|
||
getLoginMethod,
|
||
getResourceQuantity,
|
||
getServerRegion,
|
||
getSkinGroup,
|
||
hasAcceleratedSaleRatio,
|
||
hasGiftResources,
|
||
readAssetNumber,
|
||
readAssetString,
|
||
} 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);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 排序 */
|
||
/* ------------------------------------------------------------------ */
|
||
const sortBy = ref("comprehensive");
|
||
|
||
const sortOptions = [
|
||
{ 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 || "综合排序"
|
||
);
|
||
|
||
function selectSort(key: string) {
|
||
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 }>
|
||
>;
|
||
|
||
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: "" },
|
||
],
|
||
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 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)
|
||
)
|
||
);
|
||
|
||
type FilterSection =
|
||
| {
|
||
key: string;
|
||
title: string;
|
||
type: "range";
|
||
unit?: string;
|
||
minPlaceholder?: string;
|
||
maxPlaceholder?: string;
|
||
}
|
||
| { key: string; title: string; type: "chips"; options: string[] };
|
||
|
||
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 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 next = selected.includes(value)
|
||
? 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: "" };
|
||
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;
|
||
}
|
||
|
||
function isSkinGroupKey(key: string) {
|
||
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);
|
||
}
|
||
|
||
function setFilterTabRef(key: string, el: Element | null) {
|
||
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;
|
||
container.scrollTo({
|
||
top: Math.max(target.offsetTop - 8, 0),
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
|
||
function handleFilterScroll() {
|
||
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;
|
||
}
|
||
if (activeKey && activeKey !== activeFilterSection.value) {
|
||
activeFilterSection.value = activeKey;
|
||
}
|
||
}
|
||
|
||
function toggleSortPanel() {
|
||
sortOpen.value = !sortOpen.value;
|
||
}
|
||
|
||
function clearFilters() {
|
||
selectedFilters.value = {};
|
||
rangeFilters.value = {};
|
||
searchValue.value = "";
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 筛选 + 排序后的列表 */
|
||
/* ------------------------------------------------------------------ */
|
||
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 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);
|
||
});
|
||
|
||
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 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)
|
||
);
|
||
}
|
||
if (sortBy.value === "priceDesc") {
|
||
return sorted.sort(
|
||
(a, b) => getListingDisplayPrice(b) - getListingDisplayPrice(a)
|
||
);
|
||
}
|
||
if (sortBy.value === "awmDesc") {
|
||
return sorted.sort(
|
||
(a, b) =>
|
||
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;
|
||
});
|
||
}
|
||
|
||
function uniqueOptions(values: string[]) {
|
||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function parseQuantityUnit(price: string) {
|
||
const unit = price.split("/")[1]?.trim();
|
||
return unit || undefined;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="pc-listings">
|
||
<!-- 防骗提示 -->
|
||
<div class="fraud-tip">
|
||
<span class="fraud-dot"></span>
|
||
<span
|
||
v-for="(text, idx) in announcements"
|
||
:key="idx"
|
||
class="fraud-text"
|
||
>{{ text }}</span
|
||
>
|
||
</div>
|
||
|
||
<!-- 页头 -->
|
||
<div class="listings-header">
|
||
<h1>租号大厅</h1>
|
||
<span class="result-count">
|
||
<strong>{{ filteredListings.length }}</strong> 个可租账号
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 搜索 + 排序 + 筛选入口 -->
|
||
<div class="toolbar">
|
||
<div class="search-box">
|
||
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
||
<path
|
||
fill-rule="evenodd"
|
||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||
clip-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
<input v-model="searchValue" placeholder="搜区服 / 段位 / 哈夫币数量" />
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="toolbar-btn sort-btn"
|
||
@click="toggleSortPanel"
|
||
>
|
||
<span>{{ activeSortLabel }}</span>
|
||
<svg
|
||
:class="{ rotated: sortOpen }"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
width="14"
|
||
height="14"
|
||
>
|
||
<path
|
||
fill-rule="evenodd"
|
||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||
clip-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
<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"
|
||
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 10.414V15a1 1 0 01-.553.894l-2 1A1 1 0 018 16v-5.586L3.293 5.707A1 1 0 013 5V3z"
|
||
clip-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
<span>筛选</span>
|
||
<em v-if="activeFilterCount">{{ activeFilterCount }}</em>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 排序下拉 -->
|
||
<Transition name="sort-panel">
|
||
<div v-if="sortOpen" class="sort-panel">
|
||
<button
|
||
v-for="option in sortOptions"
|
||
:key="option.key"
|
||
type="button"
|
||
:class="{ active: sortBy === option.key }"
|
||
@click="selectSort(option.key)"
|
||
>
|
||
<span>{{ option.label }}</span>
|
||
<svg
|
||
v-if="sortBy === option.key"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
width="16"
|
||
height="16"
|
||
>
|
||
<path
|
||
fill-rule="evenodd"
|
||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||
clip-rule="evenodd"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- ================== 筛选侧栏 ================== -->
|
||
<Transition name="filter-sidebar">
|
||
<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>
|
||
</header>
|
||
|
||
<div class="filter-sidebar-body">
|
||
<nav class="filter-tabs">
|
||
<button
|
||
v-for="section in filterSections"
|
||
:key="section.key"
|
||
:ref="(el) => setFilterTabRef(section.key, el as Element | null)"
|
||
type="button"
|
||
:class="{ active: activeFilterSection === section.key }"
|
||
@click="scrollToFilterSection(section.key)"
|
||
>
|
||
{{ section.title }}
|
||
</button>
|
||
</nav>
|
||
|
||
<section
|
||
ref="filterContentRef"
|
||
class="filter-content"
|
||
@scroll.passive="handleFilterScroll"
|
||
>
|
||
<div
|
||
v-for="section in filterSections"
|
||
:key="section.key"
|
||
: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"
|
||
>
|
||
单位:{{ section.unit }}
|
||
</p>
|
||
|
||
<template v-if="section.type === 'range'">
|
||
<div class="range-editor">
|
||
<input
|
||
:value="rangeFilters[section.key]?.min || ''"
|
||
type="number"
|
||
inputmode="decimal"
|
||
:placeholder="section.minPlaceholder || '最低'"
|
||
@input="
|
||
updateRange(
|
||
section.key,
|
||
'min',
|
||
($event.target as HTMLInputElement).value
|
||
)
|
||
"
|
||
/>
|
||
<span class="range-sep">—</span>
|
||
<input
|
||
:value="rangeFilters[section.key]?.max || ''"
|
||
type="number"
|
||
inputmode="decimal"
|
||
:placeholder="section.maxPlaceholder || '最高'"
|
||
@input="
|
||
updateRange(
|
||
section.key,
|
||
'max',
|
||
($event.target as HTMLInputElement).value
|
||
)
|
||
"
|
||
/>
|
||
</div>
|
||
<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
|
||
),
|
||
}"
|
||
@click="
|
||
applyRangePreset(section.key, preset.min, preset.max)
|
||
"
|
||
>
|
||
{{ preset.label }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="chip-grid">
|
||
<button
|
||
v-for="option in section.options"
|
||
:key="`${section.key}-${option}`"
|
||
type="button"
|
||
:class="{
|
||
active: selectedFilters[section.key]?.includes(option),
|
||
}"
|
||
@click="toggleChip(section.key, option)"
|
||
>
|
||
{{ option }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</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>
|
||
</footer>
|
||
</aside>
|
||
</Transition>
|
||
|
||
<!-- 筛选遮罩 -->
|
||
<Transition name="overlay">
|
||
<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="filteredListings.length === 0" class="state-empty">
|
||
<p>没有符合条件的账号</p>
|
||
<button type="button" class="reset-btn" @click="clearFilters">
|
||
重置条件
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 列表 -->
|
||
<div v-else class="pc-resource-list">
|
||
<RouterLink
|
||
v-for="item in filteredListings"
|
||
:key="item.id"
|
||
class="resource-card"
|
||
:to="`/listings/${item.id}`"
|
||
>
|
||
<div class="resource-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="cover-labels"
|
||
>
|
||
<em v-if="hasGiftResources(item)">有赠送</em>
|
||
<em v-if="hasAcceleratedSaleRatio(item)">特惠</em>
|
||
</div>
|
||
</div>
|
||
<div class="resource-main">
|
||
<h2 class="card-title">{{ getListingTitle(item) }}</h2>
|
||
<p class="card-subtitle">{{ getListingSubtitle(item) }}</p>
|
||
<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>
|
||
</div>
|
||
<div class="card-chip-row">
|
||
<span
|
||
v-for="chip in getListingChips(item)"
|
||
:key="`${item.id}-${chip.label}`"
|
||
>{{ chip.label }}:{{ chip.value }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="resource-price-box">
|
||
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
|
||
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span>
|
||
</div>
|
||
</RouterLink>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ================================================================== */
|
||
/* 页面容器 */
|
||
/* ================================================================== */
|
||
.pc-listings {
|
||
position: relative;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
padding: 0 20px 40px;
|
||
color: #1a202c;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 防骗提示 */
|
||
/* ================================================================== */
|
||
.fraud-tip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
margin-bottom: 12px;
|
||
border-radius: 10px;
|
||
background: #fffdf0;
|
||
border: 1px solid #f5e6a3;
|
||
font-size: 13px;
|
||
color: #8a6d1b;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.fraud-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: #f5a623;
|
||
flex-shrink: 0;
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
|
||
.fraud-text + .fraud-text::before {
|
||
content: " | ";
|
||
color: #d4c47a;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 页头 */
|
||
/* ================================================================== */
|
||
.listings-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 14px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.listings-header h1 {
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 900;
|
||
color: #17233d;
|
||
}
|
||
|
||
.result-count {
|
||
color: #8b9cb5;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.result-count strong {
|
||
color: #1477ff;
|
||
font-weight: 800;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 工具栏:搜索 + 排序 + 筛选 */
|
||
/* ================================================================== */
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 0 1 360px;
|
||
height: 38px;
|
||
padding: 0 12px;
|
||
border-radius: 10px;
|
||
background: #f4f6f8;
|
||
border: 1px solid transparent;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.search-box:focus-within {
|
||
border-color: #1477ff;
|
||
box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.1);
|
||
background: #fff;
|
||
}
|
||
|
||
.search-icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
color: #a0aab6;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.search-box input {
|
||
border: none;
|
||
outline: none;
|
||
background: transparent;
|
||
color: #17233d;
|
||
font-size: 13px;
|
||
width: 100%;
|
||
}
|
||
|
||
.search-box input::placeholder {
|
||
color: #a0aab6;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 0 14px;
|
||
height: 38px;
|
||
border: 1px solid #e1e7ef;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: #3d4e63;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
border-color: #1477ff;
|
||
}
|
||
|
||
.sort-btn svg {
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.sort-btn svg.rotated {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.filter-btn {
|
||
position: relative;
|
||
}
|
||
|
||
.filter-btn em {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
display: grid;
|
||
width: 18px;
|
||
height: 18px;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
background: #1477ff;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
font-style: normal;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 排序面板 */
|
||
/* ================================================================== */
|
||
.sort-panel {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 8px 0;
|
||
margin-bottom: 12px;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
border: 1px solid #e1e7ef;
|
||
}
|
||
|
||
.sort-panel button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: none;
|
||
color: #5a6577;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
|
||
.sort-panel button:hover {
|
||
background: #f0f5ff;
|
||
}
|
||
|
||
.sort-panel button.active {
|
||
background: #1477ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.sort-panel button.active svg {
|
||
color: #fff;
|
||
}
|
||
|
||
.sort-panel svg {
|
||
color: #1477ff;
|
||
}
|
||
|
||
.sort-panel-enter-active,
|
||
.sort-panel-leave-active {
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
}
|
||
|
||
.sort-panel-enter-from,
|
||
.sort-panel-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 筛选侧栏 */
|
||
/* ================================================================== */
|
||
.filter-sidebar {
|
||
position: fixed;
|
||
top: 0;
|
||
right: 0;
|
||
z-index: 300;
|
||
display: grid;
|
||
grid-template-rows: 52px minmax(0, 1fr) 60px;
|
||
width: min(480px, 90vw);
|
||
height: 100vh;
|
||
background: #fff;
|
||
border-left: 1px solid #eef1f5;
|
||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.filter-sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 18px;
|
||
border-bottom: 1px solid #eef1f5;
|
||
}
|
||
|
||
.filter-sidebar-header h2 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
color: #17233d;
|
||
}
|
||
|
||
.filter-close-btn {
|
||
display: grid;
|
||
width: 36px;
|
||
height: 36px;
|
||
place-items: center;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: #f4f6f8;
|
||
color: #64748b;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.filter-sidebar-body {
|
||
display: grid;
|
||
grid-template-columns: 110px minmax(0, 1fr);
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ---- 左侧 Tab 导航 ---- */
|
||
.filter-tabs {
|
||
overflow-y: auto;
|
||
background: #f6f7f9;
|
||
border-right: 1px solid #eef1f5;
|
||
}
|
||
|
||
.filter-tabs button {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 44px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #3d4e63;
|
||
padding: 8px 12px;
|
||
text-align: left;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
|
||
.filter-tabs button:hover {
|
||
background: #edf1f7;
|
||
}
|
||
|
||
.filter-tabs button.active {
|
||
background: #fff;
|
||
color: #ff7a00;
|
||
font-weight: 800;
|
||
}
|
||
|
||
/* ---- 右侧内容 ---- */
|
||
.filter-content {
|
||
overflow-y: auto;
|
||
padding: 20px 18px 32px;
|
||
}
|
||
|
||
.filter-block {
|
||
scroll-margin-top: 8px;
|
||
}
|
||
|
||
.filter-block + .filter-block {
|
||
margin-top: 28px;
|
||
}
|
||
|
||
.filter-block h3 {
|
||
margin: 0 0 12px;
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: #17233d;
|
||
}
|
||
|
||
.filter-unit {
|
||
margin: -6px 0 10px;
|
||
color: #8a96a8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ---- Range 编辑器 ---- */
|
||
.range-editor {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.range-sep {
|
||
color: #cfd6df;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.range-editor input {
|
||
height: 40px;
|
||
border: 1px solid #e1e7ef;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: #17233d;
|
||
padding: 0 12px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
outline: none;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.range-editor input:focus {
|
||
border-color: #1477ff;
|
||
box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.08);
|
||
}
|
||
|
||
/* ---- Chip 网格 ---- */
|
||
.chip-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chip-grid button {
|
||
min-width: 0;
|
||
padding: 7px 14px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: #f4f5f7;
|
||
color: #2d3748;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||
}
|
||
|
||
.chip-grid button:hover {
|
||
background: #eaeff5;
|
||
}
|
||
|
||
.chip-grid button.active {
|
||
background: #fff3d1;
|
||
color: #cc7a00;
|
||
font-weight: 800;
|
||
box-shadow: inset 0 0 0 1px #ffd15c;
|
||
}
|
||
|
||
.range-preset-grid {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
/* ---- Footer ---- */
|
||
.filter-sidebar-footer {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 10px 18px;
|
||
border-top: 1px solid #eef1f5;
|
||
}
|
||
|
||
.filter-sidebar-footer button {
|
||
height: 42px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.reset-btn {
|
||
border: 1px solid #e1e7ef;
|
||
background: #fff;
|
||
color: #2d3748;
|
||
}
|
||
|
||
.confirm-btn {
|
||
border: none;
|
||
background: #1477ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.confirm-btn:hover {
|
||
background: #0d5fd4;
|
||
}
|
||
|
||
/* ---- 侧栏动画 ---- */
|
||
.filter-sidebar-enter-active,
|
||
.filter-sidebar-leave-active {
|
||
transition: transform 0.25s ease;
|
||
}
|
||
|
||
.filter-sidebar-enter-from,
|
||
.filter-sidebar-leave-to {
|
||
transform: translateX(100%);
|
||
}
|
||
|
||
/* ---- 遮罩 ---- */
|
||
.filter-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 299;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.overlay-enter-active,
|
||
.overlay-leave-active {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.overlay-enter-from,
|
||
.overlay-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 状态提示 */
|
||
/* ================================================================== */
|
||
.state-loading,
|
||
.state-error,
|
||
.state-empty {
|
||
text-align: center;
|
||
padding: 48px 20px;
|
||
color: #8b9cb5;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.state-empty button {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 列表卡片 */
|
||
/* ================================================================== */
|
||
.pc-resource-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.resource-card {
|
||
display: grid;
|
||
grid-template-columns: 120px minmax(0, 1fr) auto;
|
||
gap: 14px;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
border: 1px solid #eef1f5;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
transition: box-shadow 0.15s, border-color 0.15s;
|
||
}
|
||
|
||
.resource-card:hover {
|
||
border-color: #c5d5ea;
|
||
box-shadow: 0 4px 16px rgba(23, 35, 61, 0.08);
|
||
}
|
||
|
||
.resource-cover {
|
||
position: relative;
|
||
width: 120px;
|
||
height: 90px;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: #f0f5ff;
|
||
}
|
||
|
||
.resource-cover img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.resource-cover span {
|
||
display: grid;
|
||
width: 100%;
|
||
height: 100%;
|
||
place-items: center;
|
||
color: #1477ff;
|
||
font-size: 20px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.cover-labels {
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 6px;
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.cover-labels em {
|
||
font-style: normal;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
background: #ff6a00;
|
||
}
|
||
|
||
.cover-labels em + em {
|
||
background: #e74c3c;
|
||
}
|
||
|
||
.resource-main {
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-title {
|
||
margin: 0 0 4px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #17233d;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-subtitle {
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
color: #1477ff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.resource-badge-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.trust-badge,
|
||
.server-badge {
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.trust-badge {
|
||
background: #e8faf0;
|
||
color: #0d9a5f;
|
||
}
|
||
|
||
.server-badge {
|
||
background: #f0f5ff;
|
||
color: #3d6fcc;
|
||
}
|
||
|
||
.card-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.card-chip-row span {
|
||
color: #8b9cb5;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.resource-price-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.resource-price-box strong {
|
||
font-size: 20px;
|
||
font-weight: 900;
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.rent-sub {
|
||
color: #8b9cb5;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|