初始化
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 哈夫币展示页
|
||||||
|
|
||||||
|
一个轻量展示前端,数据直接读取 `hfb_sys` 的公开接口。
|
||||||
|
|
||||||
|
## 数据来源分析
|
||||||
|
|
||||||
|
- 商品列表:`GET /api/listings?page=1&page_size=12&keyword=&sort=&zone=`
|
||||||
|
- 首页配置:`GET /api/mobile-home-config`
|
||||||
|
- 图片资源:商品返回的 `cover_url`、`screenshot_urls` 已经是 `/api/...` 相对路径,前端无需额外拼接。
|
||||||
|
- 生产部署建议:展示页只作为静态站点运行,Nginx 将 `/api/` 反向代理到 `hfb_sys` 后端。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
确认 `hfb_sys` 后端已经在 `127.0.0.1:8080` 运行后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://127.0.0.1:5174`。
|
||||||
|
|
||||||
|
如果后端地址不同:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:8080 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
产物在 `dist/`。
|
||||||
|
|
||||||
|
## 部署方案
|
||||||
|
|
||||||
|
### 方案一:跟 `hfb_sys` 同机 Docker 部署
|
||||||
|
|
||||||
|
1. 先让 `hfb_sys` 的后端容器加入一个共享网络,或者创建外部网络:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create hfb_sys_net
|
||||||
|
docker network connect hfb_sys_net hfb_sys-backend-1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 确认 `nginx.conf` 里的后端主机名能解析到后端容器:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass http://hfb_sys_backend:8080/api/;
|
||||||
|
```
|
||||||
|
|
||||||
|
如果后端容器名不是 `hfb_sys_backend`,改成真实容器名或在 compose 里加 network alias。
|
||||||
|
|
||||||
|
3. 启动展示页:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问 `http://服务器IP:8090`。
|
||||||
|
|
||||||
|
### 方案二:复用现有 Nginx
|
||||||
|
|
||||||
|
执行 `npm run build`,把 `dist/` 配到一个新站点,新增:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080/api/;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样展示页和 `hfb_sys` 共用一套数据库、后台审核、图片存储和公开接口。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
hfb_show:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
networks:
|
||||||
|
- hfb_sys_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hfb_sys_net:
|
||||||
|
external: true
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>哈夫币账号展示</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://hfb_sys_backend:8080/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "hfb-show",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.17.0",
|
||||||
|
"vue": "^3.5.22"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.12",
|
||||||
|
"vue-tsc": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+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" />
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
const proxyTarget = env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user