初始化
This commit is contained in:
+318
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
+587
@@ -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%;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user