Files
hfb_sys/frontend/src/views/public/ListingsView.vue
T
2026-06-02 13:24:04 +08:00

1390 lines
34 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, 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>