初始化

This commit is contained in:
yml
2026-06-02 15:31:09 +08:00
commit 3ea0c3db7d
16 changed files with 2832 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import {
ArrowDownUp,
BadgeCheck,
Copy,
Filter,
LoaderCircle,
RefreshCw,
Search,
ShieldCheck,
Sparkles,
} from "@lucide/vue";
import { fetchListingsPage, fetchMobileHomeConfig, type HomeBannerSlide, type Listing, type PublicListingQuery } from "./api";
import {
formatCoin,
formatListingCode,
formatRatio,
getMainChips,
getOnlineTimeText,
readAssetNumber,
} from "./listingDisplay";
const pageSize = 12;
const loading = ref(false);
const loadingMore = ref(false);
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 copiedCode = ref("");
const activeSort = ref("recommended");
const activeZone = ref("all");
const filters = reactive({
keyword: "",
minCoin: "",
maxCoin: "",
minPrice: "",
maxPrice: "",
});
const sortOptions = [
{ key: "recommended", label: "默认" },
{ key: "priceAsc", label: "价格" },
{ key: "coinDesc", label: "哈夫币" },
{ key: "awmDesc", label: "AWM" },
];
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 topBanner = computed(() => banners.value[0] || {
eyebrow: "三角洲行动账号专区",
title: "高哈夫币 · 安全交接 · 随租随玩",
badge: "HOT",
pill: "租前先核对资料与截图",
tone: "blue",
});
const hasMore = computed(() => listings.value.length < total.value);
onMounted(() => {
loadHome();
});
watch(
() => [activeSort.value, activeZone.value],
() => loadListings(true)
);
async function loadHome() {
loading.value = true;
try {
const [home] = await Promise.all([fetchMobileHomeConfig(), loadListings(true)]);
announcements.value = home.announcements;
banners.value = home.banners;
} catch (error) {
loadError.value = error instanceof Error ? error.message : "加载失败";
} finally {
loading.value = false;
}
}
async function loadListings(reset = false) {
if (loadingMore.value) return;
loadingMore.value = true;
loadError.value = "";
try {
const nextPage = reset ? 1 : page.value;
const result = await fetchListingsPage(buildQuery(nextPage));
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 = [];
} finally {
loadingMore.value = false;
}
}
function buildQuery(nextPage: number): PublicListingQuery {
return {
page: nextPage,
page_size: pageSize,
keyword: filters.keyword.trim(),
sort: activeSort.value,
zone: activeZone.value,
min_coin: toNumber(filters.minCoin),
max_coin: toNumber(filters.maxCoin),
min_price: toNumber(filters.minPrice),
max_price: toNumber(filters.maxPrice),
};
}
function submitSearch() {
loadListings(true);
}
function resetFilters() {
filters.keyword = "";
filters.minCoin = "";
filters.maxCoin = "";
filters.minPrice = "";
filters.maxPrice = "";
activeSort.value = "recommended";
activeZone.value = "all";
loadListings(true);
}
async function copyListing(item: Listing) {
const text = `${formatListingCode(item)} 纯币 ${formatCoin(item)}${item.price} 比例 ${formatRatio(item)}`;
await navigator.clipboard?.writeText(text);
copiedCode.value = formatListingCode(item);
window.setTimeout(() => {
if (copiedCode.value === formatListingCode(item)) copiedCode.value = "";
}, 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;
}
</script>
<template>
<main class="page">
<section class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark"><Sparkles :size="22" /></div>
<div>
<strong>哈夫币展示</strong>
<span>实时同步平台账号</span>
</div>
</div>
<form class="search" @submit.prevent="submitSearch">
<Search :size="18" />
<input v-model="filters.keyword" placeholder="搜索编号 / 刀皮 / 枪皮 / 段位..." />
<button type="submit">搜索</button>
</form>
</header>
<section
class="hero"
:class="`hero-${topBanner.tone}`"
:style="topBanner.image_url ? { backgroundImage: `linear-gradient(90deg, rgba(13, 18, 30, .82), rgba(13, 18, 30, .18)), url(${topBanner.image_url})` } : undefined"
>
<div class="hero-copy">
<span>{{ topBanner.eyebrow || "三角洲行动账号专区" }}</span>
<h1>{{ topBanner.title || "高哈夫币 · 安全交接 · 随租随玩" }}</h1>
<p>{{ topBanner.pill || "租前先核对哈夫币、保险、体力负重和截图信息" }}</p>
</div>
<div class="hero-badge">{{ topBanner.badge || "HOT" }}</div>
</section>
<div v-if="announcements.length" class="notice">
<ShieldCheck :size="17" />
<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">
<button
v-for="sort in sortOptions"
:key="sort.key"
:class="{ active: activeSort === sort.key }"
type="button"
@click="activeSort = sort.key"
>
<ArrowDownUp v-if="sort.key !== 'recommended'" :size="13" />
{{ sort.label }}
</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>
</div>
</section>
<section class="summary">
<div>
<strong>{{ total }}</strong>
<span>当前可租账号</span>
</div>
<div>
<strong>{{ zoneCount("highCoin") }}</strong>
<span>100M 以上</span>
</div>
<div>
<strong>{{ zoneCount("sale") }}</strong>
<span>特惠账号</span>
</div>
</section>
<div v-if="loading && !listings.length" class="state">
<LoaderCircle class="spin" :size="22" />
正在加载账号
</div>
<div v-else-if="loadError && !listings.length" class="state error">
{{ loadError }}
<button type="button" @click="loadHome">重试</button>
</div>
<section v-else class="grid">
<article v-for="item in listings" :key="item.id" class="card">
<div class="card-head">
<div>
<h2>编号 {{ formatListingCode(item) }}</h2>
<p>{{ item.server_region || "未填写区服" }} · {{ item.login_platform || "上号方式待确认" }}</p>
</div>
<div class="price">
<strong>{{ Math.round(item.price) }}</strong>
<span>押金:{{ Math.round(item.deposit_amount) }}</span>
</div>
</div>
<div class="coin-row">
<strong>纯币{{ formatCoin(item) }}</strong>
<span>比例 {{ formatRatio(item) }}</span>
</div>
<div class="badges">
<span v-if="item.is_accelerated_sale" class="badge hot"><BadgeCheck :size="13" /> 特惠</span>
<span v-if="getOnlineTimeText(item)" class="badge">{{ getOnlineTimeText(item) }}</span>
<span v-if="readAssetNumber(item, 'fire_level')" class="badge">烽火 {{ readAssetNumber(item, "fire_level") }}</span>
</div>
<div class="chips">
<span v-for="chip in getMainChips(item)" :key="`${item.id}-${chip.label}`" :class="`chip chip-${chip.tone}`">
{{ chip.label }}
</span>
</div>
<div v-if="item.asset_summary?.remark" class="remark">
备注: {{ item.asset_summary.remark }}
</div>
<button class="copy" type="button" @click="copyListing(item)">
<Copy :size="15" />
{{ copiedCode === formatListingCode(item) ? "已复制" : "复制" }}
</button>
</article>
</section>
<div class="loadmore">
<button v-if="hasMore" type="button" :disabled="loadingMore" @click="loadListings(false)">
<LoaderCircle v-if="loadingMore" class="spin" :size="16" />
<ArrowDownUp v-else :size="16" />
{{ loadingMore ? "加载中" : "加载更多" }}
</button>
<span v-else-if="listings.length">已经到底了</span>
</div>
</section>
</main>
</template>
+141
View File
@@ -0,0 +1,141 @@
export interface ApiResponse<T> {
code: string;
message: string;
data: T;
}
export interface ListingResource {
key: string;
label: string;
price: string;
quantity: number;
mode: string;
}
export interface Listing {
id: number;
title: string;
description: string;
game_name: string;
server_region: string;
login_platform: string;
rank_level: string;
haf_coin_amount: number;
asset_summary?: Record<string, unknown>;
screenshot_urls: string[];
cover_url: string;
price: number;
deposit_amount: number;
is_accelerated_sale?: boolean;
in_transaction: boolean;
published_at?: string;
}
export interface PublicListingPage {
items: Listing[];
total: number;
page: number;
page_size: number;
zone_counts: Record<string, number>;
}
export interface PublicListingQuery {
page?: number;
page_size?: number;
keyword?: string;
sort?: string;
zone?: string;
min_coin?: number;
max_coin?: number;
min_price?: number;
max_price?: number;
}
export interface HomeBannerSlide {
eyebrow: string;
title: string;
badge: string;
pill: string;
tone: string;
image_url?: string;
}
export interface ListingPublishOptions {
server_options: string[];
login_method_options: string[];
rank_options: string[];
insurance_options: string[];
level_options: string[];
}
export interface MobileHomeConfig {
announcements: string[];
banners: HomeBannerSlide[];
publish_options: ListingPublishOptions;
}
const apiBase = import.meta.env.VITE_API_BASE_URL || "/api";
export async function fetchListingsPage(query: PublicListingQuery) {
return request<Partial<PublicListingPage>>("/listings", { ...query }).then((data) => ({
items: Array.isArray(data.items) ? data.items : [],
total: Number(data.total || 0),
page: Number(data.page || query.page || 1),
page_size: Number(data.page_size || query.page_size || 12),
zone_counts: data.zone_counts && typeof data.zone_counts === "object" ? data.zone_counts : {},
}));
}
export async function fetchMobileHomeConfig() {
return request<Partial<MobileHomeConfig>>("/mobile-home-config").then((data) => ({
announcements: normalizeStringArray(data.announcements),
banners: normalizeBanners(data.banners),
publish_options: normalizeOptions(data.publish_options),
}));
}
function normalizeStringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim() !== "") : [];
}
function normalizeBanners(value: unknown): HomeBannerSlide[] {
if (!Array.isArray(value)) return [];
return value
.filter((item): item is Partial<HomeBannerSlide> => typeof item === "object" && item !== null)
.map((item) => ({
eyebrow: item.eyebrow || "",
title: item.title || "",
badge: item.badge || "",
pill: item.pill || "",
tone: item.tone || "blue",
image_url: item.image_url || "",
}))
.filter((item) => item.title || item.image_url);
}
function normalizeOptions(value: unknown): ListingPublishOptions {
const options = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
return {
server_options: normalizeStringArray(options.server_options),
login_method_options: normalizeStringArray(options.login_method_options),
rank_options: normalizeStringArray(options.rank_options),
insurance_options: normalizeStringArray(options.insurance_options),
level_options: normalizeStringArray(options.level_options),
};
}
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]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
const response = await fetch(url);
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
const payload = (await response.json()) as ApiResponse<T>;
return payload.data;
}
+95
View File
@@ -0,0 +1,95 @@
import type { Listing, ListingResource } from "./api";
export function formatListingCode(item: Listing) {
return `SP${String(item.id).padStart(4, "0")}`;
}
export function formatCoin(item: Listing) {
const coinM = Number(item.haf_coin_amount || 0) / 1_000_000;
const rounded = Math.round(coinM * 10) / 10;
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`;
}
export function formatRatio(item: Listing) {
const configuredRatio = readAssetNumber(item, "publish_ratio");
if (configuredRatio > 0) return `1:${formatRatioNumber(configuredRatio)}`;
if (!item.price) return "--";
const coinWan = Number(item.haf_coin_amount || 0) / 10000;
return `1:${formatRatioNumber(coinWan / item.price)}`;
}
export function readAssetString(item: Listing, key: string) {
const value = item.asset_summary?.[key];
return typeof value === "string" ? value : "";
}
export function readAssetNumber(item: Listing, key: string) {
const value = item.asset_summary?.[key];
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
export function getResources(item: Listing): ListingResource[] {
const resources = item.asset_summary?.resources;
if (!Array.isArray(resources)) return [];
return resources
.filter((resource): resource is Record<string, unknown> => typeof resource === "object" && resource !== null)
.map((resource) => ({
key: String(resource.key || ""),
label: String(resource.label || ""),
price: String(resource.price || ""),
quantity: Number(resource.quantity || 0),
mode: String(resource.mode || ""),
}))
.filter((resource) => resource.key && resource.label && resource.quantity > 0);
}
export function getResourceQuantity(item: Listing, key: string) {
return getResources(item).find((resource) => resource.key === key)?.quantity || 0;
}
export function getSkinNames(item: Listing) {
const skinGroups = item.asset_summary?.skin_groups;
if (typeof skinGroups !== "object" || skinGroups === null) return [];
return Object.values(skinGroups as Record<string, unknown>)
.flatMap((group) => (Array.isArray(group) ? group : []))
.filter((skin): skin is string => typeof skin === "string" && skin.trim() !== "");
}
export function getRegions(item: Listing) {
const regions = item.asset_summary?.common_regions;
return Array.isArray(regions) ? regions.filter((region): region is string => typeof region === "string") : [];
}
export function getOnlineTimeText(item: Listing) {
const onlineTime = item.asset_summary?.online_time;
if (typeof onlineTime !== "object" || onlineTime === null) return "";
const start = (onlineTime as Record<string, unknown>).start;
const end = (onlineTime as Record<string, unknown>).end;
if (typeof start !== "string" || typeof end !== "string") return "";
return `${start.replace(":00", "")}-${end.replace(":00", "")}`;
}
export function getMainChips(item: Listing) {
return [
{ label: readAssetString(item, "stamina_level") || "体力--", tone: "green" },
{ label: readAssetString(item, "load_level") || "负重--", tone: "green" },
{ label: readAssetString(item, "season_insurance") || "保险--", tone: "red" },
{ label: item.rank_level || "段位--", tone: "red" },
{ label: `${getResourceQuantity(item, "armor6")}`, tone: "red" },
{ label: `${getResourceQuantity(item, "helmet6")}`, tone: "red" },
{ label: `AWM子弹:${getResourceQuantity(item, "awmAmmo")}`, tone: "red" },
{ label: `KD:${readAssetNumber(item, "secret_kd") || "--"}`, tone: "red" },
...getRegions(item).slice(0, 2).map((region) => ({ label: region, tone: "purple" })),
...getSkinNames(item).slice(0, 2).map((skin) => ({ label: skin, tone: "gold" })),
].filter((chip) => chip.label && !chip.label.includes(":0发") && chip.label !== "0甲" && chip.label !== "0头");
}
function formatRatioNumber(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");
+587
View File
@@ -0,0 +1,587 @@
:root {
color: #2f3137;
background: #f3f3f4;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: #f3f3f4;
overflow-x: hidden;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.page {
min-height: 100vh;
padding: 0 16px 32px;
}
.shell {
width: min(1116px, 100%);
margin: 0 auto;
background: #f7f7f8;
min-height: 100vh;
padding: 12px 12px 28px;
}
.topbar {
display: grid;
grid-template-columns: max-content 1fr;
gap: 16px;
align-items: center;
padding: 2px 0 12px;
background: #fff;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.brand-mark {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 2px solid #f5c451;
border-radius: 50%;
color: #a56a00;
background: #fff8d9;
}
.brand strong,
.brand span {
display: block;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
}
.brand span {
color: #8e929c;
font-size: 11px;
margin-top: 2px;
}
.search {
height: 38px;
border: 1px solid #ffad1f;
border-radius: 999px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px 0 14px;
background: #fff;
}
.search svg {
color: #9a9da5;
flex: none;
}
.search input {
border: 0;
outline: 0;
flex: 1;
min-width: 0;
color: #33363d;
}
.search button,
.loadmore button,
.state button {
border: 0;
border-radius: 6px;
background: #ff6680;
color: #fff;
min-height: 30px;
padding: 0 16px;
font-weight: 700;
}
.hero {
position: relative;
min-height: 118px;
border-radius: 8px;
overflow: hidden;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 18px 28px;
background-size: cover;
background-position: center;
isolation: isolate;
}
.hero::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
linear-gradient(90deg, rgba(23, 26, 32, 0.88), rgba(23, 26, 32, 0.12)),
radial-gradient(circle at 75% 50%, rgba(255, 209, 79, 0.48), transparent 28%),
linear-gradient(135deg, #242734, #62342c 54%, #14161d);
}
.hero-green::before {
background:
linear-gradient(90deg, rgba(20, 32, 29, 0.88), rgba(20, 32, 29, 0.18)),
radial-gradient(circle at 74% 40%, rgba(92, 214, 161, 0.38), transparent 28%),
linear-gradient(135deg, #192d2b, #2f5946 56%, #15191d);
}
.hero-orange::before {
background:
linear-gradient(90deg, rgba(33, 25, 19, 0.9), rgba(33, 25, 19, 0.16)),
radial-gradient(circle at 74% 44%, rgba(255, 176, 66, 0.42), transparent 30%),
linear-gradient(135deg, #2a211d, #74512d 55%, #17171c);
}
.hero-copy span,
.hero-copy p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
}
.hero-copy h1 {
margin: 8px 0;
font-size: clamp(24px, 4vw, 38px);
line-height: 1.05;
letter-spacing: 0;
}
.hero-badge {
width: 72px;
height: 72px;
border-radius: 50%;
display: grid;
place-items: center;
border: 2px solid rgba(255, 255, 255, 0.68);
background: rgba(255, 255, 255, 0.16);
font-weight: 900;
}
.notice {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
color: #656a76;
padding: 0 8px;
font-size: 13px;
}
.notice span,
.segmented,
.sorts,
.rangebar,
.summary,
.grid {
min-width: 0;
}
.notice svg {
color: #24a662;
flex: none;
}
.filters {
display: grid;
gap: 10px;
padding: 10px;
background: #fff;
min-width: 0;
overflow: hidden;
}
.segmented,
.sorts {
display: flex;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
}
.segmented button,
.sorts button {
min-height: 34px;
border: 0;
background: transparent;
color: #777b85;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 6px;
padding: 0 18px;
font-weight: 700;
}
.segmented button.active,
.sorts button.active {
color: #ff506d;
background: #fff0f3;
}
.segmented span {
color: #b4b7bd;
font-size: 12px;
}
.rangebar {
display: grid;
grid-template-columns: 1fr 1fr 36px 36px;
gap: 8px;
}
.rangebar label {
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;
color: #33363d;
}
.summary span {
font-size: 12px;
color: #8e929c;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.card {
position: relative;
min-height: 206px;
background: #fff;
border-radius: 8px;
padding: 16px 12px 50px;
box-shadow: 0 2px 12px rgba(24, 26, 32, 0.06);
}
.card-head,
.coin-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.card h2 {
margin: 0;
font-size: 20px;
line-height: 1.2;
letter-spacing: 0;
}
.card p {
margin: 6px 0 0;
color: #8e929c;
font-size: 12px;
}
.price {
text-align: right;
flex: none;
}
.price strong {
color: #ff435f;
font-size: 21px;
}
.price span {
display: block;
color: #8b8f99;
font-size: 11px;
}
.coin-row {
align-items: center;
border-bottom: 1px solid #f0f1f4;
margin: 8px 0 10px;
padding-bottom: 10px;
}
.coin-row strong {
font-size: 20px;
}
.coin-row span {
color: #ff435f;
font-size: 12px;
}
.badges,
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badges {
margin-bottom: 8px;
}
.badge,
.chip {
max-width: 100%;
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 5px;
font-size: 12px;
padding: 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
background: #f0f8f3;
color: #1e9b59;
}
.badge.hot {
background: #fff0f2;
color: #ff435f;
}
.chip-green {
color: #169950;
background: #effbf4;
border: 1px solid #c8f0d7;
}
.chip-red {
color: #ff435f;
background: #fff3f5;
border: 1px solid #ffd7df;
}
.chip-purple {
color: #6d44e8;
background: #f4f0ff;
border: 1px solid #ddd2ff;
}
.chip-gold {
color: #b87400;
background: #fff7df;
border: 1px solid #ffe6a0;
}
.remark {
margin-top: 8px;
color: #ff6a00;
font-size: 12px;
line-height: 1.5;
}
.copy {
position: absolute;
right: 12px;
bottom: 12px;
min-width: 64px;
height: 30px;
border: 0;
border-radius: 6px;
background: #ff6f86;
color: #fff;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
.state,
.loadmore {
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #8c909a;
}
.state.error {
color: #ff435f;
}
.loadmore button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.loadmore span {
color: #999da7;
font-size: 13px;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 920px) {
.topbar {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rangebar {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.page {
padding: 0;
}
.shell {
padding: 10px;
}
.hero {
min-height: 108px;
padding: 16px;
}
.hero-badge {
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;
}
.grid {
grid-template-columns: 1fr;
gap: 12px;
}
.rangebar {
grid-template-columns: 1fr;
}
.rangebar label {
grid-template-columns: 52px 1fr 1fr;
}
.icon-action {
width: 100%;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />