切换 react-antd
This commit is contained in:
+1
-1
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1489
-1021
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -5,17 +5,20 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build": "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",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.12",
|
||||
"vue-tsc": "^3.1.1"
|
||||
"vite": "^8.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"antd": "^6.5.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
+719
@@ -0,0 +1,719 @@
|
||||
import CopyOutlined from "@ant-design/icons/es/icons/CopyOutlined";
|
||||
import DownOutlined from "@ant-design/icons/es/icons/DownOutlined";
|
||||
import ReloadOutlined from "@ant-design/icons/es/icons/ReloadOutlined";
|
||||
import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined";
|
||||
import StarFilled from "@ant-design/icons/es/icons/StarFilled";
|
||||
import Alert from "antd/es/alert";
|
||||
import Button from "antd/es/button";
|
||||
import ConfigProvider from "antd/es/config-provider";
|
||||
import Empty from "antd/es/empty";
|
||||
import Input from "antd/es/input";
|
||||
import Pagination from "antd/es/pagination";
|
||||
import Select from "antd/es/select";
|
||||
import Spin from "antd/es/spin";
|
||||
import Tag from "antd/es/tag";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchListingsPage,
|
||||
fetchMobileHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
type Listing,
|
||||
type ListingPublishOptions,
|
||||
type PublicListingQuery,
|
||||
} from "./api";
|
||||
import {
|
||||
formatCoin,
|
||||
formatListingCode,
|
||||
formatRatio,
|
||||
getMainChips,
|
||||
getOnlineTimeText,
|
||||
getRegions,
|
||||
getResourceQuantity,
|
||||
getSkinNames,
|
||||
readAssetNumber,
|
||||
readAssetString,
|
||||
} from "./listingDisplay";
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const initialOptions: ListingPublishOptions = {
|
||||
server_options: [],
|
||||
login_method_options: [],
|
||||
rank_options: [],
|
||||
insurance_options: [],
|
||||
level_options: [],
|
||||
skin_groups: [],
|
||||
};
|
||||
|
||||
const fallbackLevels = ["4级", "5级", "6级", "7级"];
|
||||
const fallbackInsurance = ["1*2", "2*2", "2*3", "3*3"];
|
||||
const fallbackLogins = ["QQ", "微信", "Steam", "不限"];
|
||||
const fallbackRanks = ["青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "巅峰"];
|
||||
const fallbackSkins = ["信条", "坠星者", "处刑者", "影锋", "暗星", "电锯", "蚀金玫瑰", "维什戴尔"];
|
||||
|
||||
const coinRanges = [
|
||||
{ label: "15-50", min: "15", max: "50" },
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-150", min: "100", max: "150" },
|
||||
{ label: "150-200", min: "150", max: "200" },
|
||||
{ label: "200-250", min: "200", max: "250" },
|
||||
{ label: "300以上", min: "300", max: "" },
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
{ label: "0-50", min: "0", max: "50" },
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-150", min: "100", max: "150" },
|
||||
{ label: "150-200", min: "150", max: "200" },
|
||||
{ label: "200以上", min: "200", max: "" },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "recommended", label: "默认排序" },
|
||||
{ key: "priceDesc", label: "价格排序" },
|
||||
{ key: "ratioDesc", label: "比例排序" },
|
||||
{ key: "coinDesc", label: "哈夫币排序" },
|
||||
{ key: "ratioAsc", label: "绝密KD排序" },
|
||||
{ key: "coinAsc", label: "AWM排序" },
|
||||
{ key: "priceAsc", label: "六头排序" },
|
||||
{ key: "loadDesc", label: "六甲排序" },
|
||||
];
|
||||
|
||||
interface Filters {
|
||||
keyword: string;
|
||||
minCoin: string;
|
||||
maxCoin: string;
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
minKd: string;
|
||||
maxKd: string;
|
||||
stamina: string[];
|
||||
load: string[];
|
||||
insurance: string[];
|
||||
skinNames: string[];
|
||||
loginMethods: string[];
|
||||
ranks: string[];
|
||||
zones: string[];
|
||||
}
|
||||
|
||||
const emptyFilters: Filters = {
|
||||
keyword: "",
|
||||
minCoin: "",
|
||||
maxCoin: "",
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
minKd: "",
|
||||
maxKd: "",
|
||||
stamina: [],
|
||||
load: [],
|
||||
insurance: [],
|
||||
skinNames: [],
|
||||
loginMethods: [],
|
||||
ranks: [],
|
||||
zones: [],
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: "#00d7a0",
|
||||
borderRadius: 5,
|
||||
fontFamily: 'Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
controlHeight: 31,
|
||||
borderRadius: 4,
|
||||
},
|
||||
Input: {
|
||||
controlHeight: 38,
|
||||
},
|
||||
Select: {
|
||||
controlHeight: 30,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Showcase />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Showcase() {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [announcements, setAnnouncements] = useState<string[]>([]);
|
||||
const [banners, setBanners] = useState<HomeBannerSlide[]>([]);
|
||||
const [publishOptions, setPublishOptions] = useState<ListingPublishOptions>(initialOptions);
|
||||
const [filters, setFilters] = useState<Filters>(cloneFilters(emptyFilters));
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(cloneFilters(emptyFilters));
|
||||
const [activeSort, setActiveSort] = useState("recommended");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [copiedCode, setCopiedCode] = useState("");
|
||||
|
||||
const levelOptions = useMemo(
|
||||
() => (publishOptions.level_options.length ? publishOptions.level_options : fallbackLevels),
|
||||
[publishOptions.level_options]
|
||||
);
|
||||
const insuranceOptions = useMemo(
|
||||
() => (publishOptions.insurance_options.length ? publishOptions.insurance_options : fallbackInsurance),
|
||||
[publishOptions.insurance_options]
|
||||
);
|
||||
const loginOptions = useMemo(
|
||||
() => (publishOptions.login_method_options.length ? publishOptions.login_method_options : fallbackLogins),
|
||||
[publishOptions.login_method_options]
|
||||
);
|
||||
const rankOptions = useMemo(
|
||||
() => (publishOptions.rank_options.length ? publishOptions.rank_options : fallbackRanks),
|
||||
[publishOptions.rank_options]
|
||||
);
|
||||
const zoneOptions = useMemo(() => publishOptions.server_options, [publishOptions.server_options]);
|
||||
const skinSections = useMemo(() => {
|
||||
const groups = publishOptions.skin_groups.filter((group) =>
|
||||
["melee", "operatorGold", "operatorRed", "weapon"].includes(group.key)
|
||||
);
|
||||
if (groups.length) {
|
||||
return groups.map((group) => ({
|
||||
key: group.key,
|
||||
title: groupTitle(group.key, group.title),
|
||||
options: group.options,
|
||||
}));
|
||||
}
|
||||
return [{ key: "skin", title: "稀有皮肤", options: fallbackSkins }];
|
||||
}, [publishOptions.skin_groups]);
|
||||
|
||||
const topBanner = useMemo(
|
||||
() =>
|
||||
banners[0] || {
|
||||
eyebrow: "三角洲行动账号专区",
|
||||
title: "高哈夫币账号实时展示",
|
||||
badge: "24H",
|
||||
pill: "安全交易 · 诚信服务 · 专业保障",
|
||||
tone: "green",
|
||||
},
|
||||
[banners]
|
||||
);
|
||||
|
||||
const displayListings = useMemo(() => {
|
||||
const items = [...listings];
|
||||
if (activeSort === "ratioAsc") return items.sort((a, b) => ratioValue(a) - ratioValue(b));
|
||||
if (activeSort === "ratioDesc") return items.sort((a, b) => ratioValue(b) - ratioValue(a));
|
||||
if (activeSort === "coinAsc") return items.sort((a, b) => a.haf_coin_amount - b.haf_coin_amount);
|
||||
if (activeSort === "loadDesc") {
|
||||
return items.sort((a, b) => getResourceQuantity(b, "armor6") - getResourceQuantity(a, "armor6"));
|
||||
}
|
||||
return items;
|
||||
}, [activeSort, listings]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
fetchMobileHomeConfig()
|
||||
.then((home) => {
|
||||
if (disposed) return;
|
||||
setAnnouncements(home.announcements);
|
||||
setBanners(home.banners);
|
||||
setPublishOptions(home.publish_options);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setPublishOptions(initialOptions);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
fetchListingsPage(buildQuery(page, appliedFilters, activeSort))
|
||||
.then((result) => {
|
||||
if (disposed) return;
|
||||
setListings(result.items);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (disposed) return;
|
||||
setListings([]);
|
||||
setTotal(0);
|
||||
setLoadError(error instanceof Error ? error.message : "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [activeSort, appliedFilters, page]);
|
||||
|
||||
function updateFilter<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleFilterList(key: "stamina" | "load" | "insurance" | "skinNames", value: string) {
|
||||
setFilters((current) => {
|
||||
const nextList = current[key].includes(value) ? current[key].filter((item) => item !== value) : [...current[key], value];
|
||||
return { ...current, [key]: nextList };
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters(nextFilters = filters) {
|
||||
setAppliedFilters(cloneFilters(nextFilters));
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
const nextFilters = cloneFilters(emptyFilters);
|
||||
setFilters(nextFilters);
|
||||
setAppliedFilters(cloneFilters(nextFilters));
|
||||
setActiveSort("recommended");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function selectRange(minKey: "minCoin" | "minPrice", maxKey: "maxCoin" | "maxPrice", min: string, max: string) {
|
||||
setFilters((current) => ({ ...current, [minKey]: min, [maxKey]: max }));
|
||||
}
|
||||
|
||||
function setSort(sortKey: string) {
|
||||
setActiveSort((current) => (current === sortKey && sortKey !== "recommended" ? "recommended" : sortKey));
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function copyListing(item: Listing) {
|
||||
const code = formatListingCode(item);
|
||||
const text = `${code} 纯币 ${formatCoin(item)} ¥${item.price} 比例 ${formatRatio(item)}`;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
setCopiedCode(code);
|
||||
window.setTimeout(() => {
|
||||
setCopiedCode((current) => (current === code ? "" : current));
|
||||
}, 1500);
|
||||
} catch {
|
||||
setCopiedCode("");
|
||||
}
|
||||
}
|
||||
|
||||
const heroStyle: CSSProperties | undefined = topBanner.image_url
|
||||
? {
|
||||
backgroundImage: `linear-gradient(90deg, rgba(17, 28, 31, .9), rgba(17, 28, 31, .42)), url(${topBanner.image_url})`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="show-page">
|
||||
<header className="site-header">
|
||||
<div className="nav-inner">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">
|
||||
<StarFilled />
|
||||
</span>
|
||||
<div>
|
||||
<strong>星逸电竞</strong>
|
||||
<span>哈夫币账号展示</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<a className="active">资源号租赁</a>
|
||||
<a>账号交易</a>
|
||||
<a>跑刀代肝</a>
|
||||
<a>租号须知</a>
|
||||
</nav>
|
||||
<div className="nav-actions">
|
||||
<span>安全交易 · 极速发货</span>
|
||||
<Button type="primary">24h在线客服</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="warning-line">禁止未成年人租号、购买游戏账号,严禁任何用户向未成年人出租、出售账号。</div>
|
||||
</header>
|
||||
|
||||
<section className="hero-strip" style={heroStyle}>
|
||||
<div>
|
||||
<span>{topBanner.eyebrow || "三角洲行动账号专区"}</span>
|
||||
<strong>{topBanner.title || "高哈夫币账号实时展示"}</strong>
|
||||
</div>
|
||||
<Tag color="success">{topBanner.badge || "HOT"}</Tag>
|
||||
<p>{topBanner.pill || "安全交易 · 诚信服务 · 专业保障"}</p>
|
||||
</section>
|
||||
|
||||
<section className="search-stage">
|
||||
<Input.Search
|
||||
enterButton={<SearchOutlined />}
|
||||
onChange={(event) => updateFilter("keyword", event.target.value)}
|
||||
onSearch={() => applyFilters()}
|
||||
placeholder="请输入编号"
|
||||
value={filters.keyword}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{announcements[0] ? <div className="notice-line">{announcements[0]}</div> : null}
|
||||
|
||||
<section className="filter-board">
|
||||
<div className="section-title">筛选条件</div>
|
||||
<div className="filter-grid">
|
||||
<FilterRow
|
||||
active={(item) => filters.minCoin === item.min && filters.maxCoin === item.max}
|
||||
label="哈夫币(M)"
|
||||
options={coinRanges}
|
||||
render={(item) => item.label}
|
||||
onSelect={(item) => selectRange("minCoin", "maxCoin", item.min, item.max)}
|
||||
/>
|
||||
<FilterRow
|
||||
active={(item) => filters.minPrice === item.min && filters.maxPrice === item.max}
|
||||
label="租金"
|
||||
options={priceRanges}
|
||||
render={(item) => item.label}
|
||||
onSelect={(item) => selectRange("minPrice", "maxPrice", item.min, item.max)}
|
||||
/>
|
||||
<FilterRow
|
||||
active={(item) => filters.insurance.includes(item)}
|
||||
label="保险"
|
||||
options={insuranceOptions}
|
||||
render={insuranceLabel}
|
||||
onSelect={(item) => toggleFilterList("insurance", item)}
|
||||
/>
|
||||
<FilterRow
|
||||
active={(item) => filters.stamina.includes(item)}
|
||||
label="体力"
|
||||
options={levelOptions}
|
||||
render={(item) => item}
|
||||
onSelect={(item) => toggleFilterList("stamina", item)}
|
||||
/>
|
||||
<FilterRow
|
||||
active={(item) => filters.load.includes(item)}
|
||||
label="负重"
|
||||
options={levelOptions}
|
||||
render={(item) => item}
|
||||
onSelect={(item) => toggleFilterList("load", item)}
|
||||
/>
|
||||
<div className="filter-row compact-select">
|
||||
<span>IP地址</span>
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
maxTagCount="responsive"
|
||||
options={zoneOptions.map((item) => ({ label: item, value: item }))}
|
||||
placeholder="全部"
|
||||
value={filters.zones}
|
||||
onChange={(value) => updateFilter("zones", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-row compact-select">
|
||||
<span>登录方式</span>
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
maxTagCount="responsive"
|
||||
options={loginOptions.map((item) => ({ label: item, value: item }))}
|
||||
placeholder="不限"
|
||||
value={filters.loginMethods}
|
||||
onChange={(value) => updateFilter("loginMethods", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-row compact-select">
|
||||
<span>段位</span>
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
maxTagCount="responsive"
|
||||
options={rankOptions.map((item) => ({ label: item, value: item }))}
|
||||
placeholder="不限"
|
||||
value={filters.ranks}
|
||||
onChange={(value) => updateFilter("ranks", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={advancedOpen ? "advanced-panel open" : "advanced-panel"}>
|
||||
<div className="range-fields">
|
||||
<label>
|
||||
<span>哈夫币范围</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
placeholder="最小"
|
||||
value={filters.minCoin}
|
||||
onChange={(event) => updateFilter("minCoin", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
placeholder="最大"
|
||||
value={filters.maxCoin}
|
||||
onChange={(event) => updateFilter("maxCoin", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>KD范围</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
placeholder="最小"
|
||||
value={filters.minKd}
|
||||
onChange={(event) => updateFilter("minKd", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
placeholder="最大"
|
||||
value={filters.maxKd}
|
||||
onChange={(event) => updateFilter("maxKd", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{skinSections.map((section) => (
|
||||
<FilterRow
|
||||
key={section.key}
|
||||
active={(item) => filters.skinNames.includes(item)}
|
||||
label={section.title}
|
||||
options={section.options.slice(0, 18)}
|
||||
render={(item) => item}
|
||||
onSelect={(item) => toggleFilterList("skinNames", item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filter-footer">
|
||||
<Button type="link" onClick={() => setAdvancedOpen((open) => !open)}>
|
||||
{advancedOpen ? "收起高级筛选" : "展开高级筛选"} <DownOutlined rotate={advancedOpen ? 180 : 0} />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="result-area">
|
||||
<div className="sort-toolbar">
|
||||
<div className="sort-buttons">
|
||||
{sortOptions.map((option) => (
|
||||
<Button key={option.key} className={activeSort === option.key ? "active" : ""} onClick={() => setSort(option.key)}>
|
||||
{option.label} <DownOutlined />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} type="primary" onClick={() => applyFilters()}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError ? <Alert showIcon type="error" message={loadError} action={<Button onClick={() => applyFilters()}>重试</Button>} /> : null}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{displayListings.length ? (
|
||||
<div className="listing-list">
|
||||
{displayListings.map((item) => (
|
||||
<ListingRow key={item.id} copied={copiedCode === formatListingCode(item)} item={item} onCopy={copyListing} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="empty-state" description={loading ? "正在加载账号" : "暂无符合条件的账号"} />
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div className="pagination-bar">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
showSizeChanger={false}
|
||||
showTotal={(count) => `第 ${page} 页 / 共 ${Math.max(1, Math.ceil(count / pageSize))} 页`}
|
||||
total={total}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="site-footer">
|
||||
<strong>星逸电竞</strong>
|
||||
<span>安全交易 · 诚信服务 · 专业保障</span>
|
||||
<small>备案信息以实际站点公示为准</small>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterRowProps<T> {
|
||||
label: string;
|
||||
options: T[];
|
||||
active: (item: T) => boolean;
|
||||
render: (item: T) => string;
|
||||
onSelect: (item: T) => void;
|
||||
}
|
||||
|
||||
function FilterRow<T>({ active, label, onSelect, options, render }: FilterRowProps<T>) {
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<span>{label}</span>
|
||||
<div className="chip-list">
|
||||
{options.map((item) => {
|
||||
const text = render(item);
|
||||
return (
|
||||
<button key={text} className={active(item) ? "selected" : ""} type="button" onClick={() => onSelect(item)}>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListingRowProps {
|
||||
item: Listing;
|
||||
copied: boolean;
|
||||
onCopy: (item: Listing) => void;
|
||||
}
|
||||
|
||||
function ListingRow({ copied, item, onCopy }: ListingRowProps) {
|
||||
const code = formatListingCode(item);
|
||||
const cover = item.cover_url || item.screenshot_urls?.[0] || "";
|
||||
const totalPrice = Math.round(Number(item.price || 0) + Number(item.deposit_amount || 0));
|
||||
const remark = getAssetText(item, "remark");
|
||||
const regions = getRegions(item);
|
||||
const skinNames = getSkinNames(item);
|
||||
const fireLevel = readAssetNumber(item, "fire_level");
|
||||
const details = [
|
||||
["哈夫币", formatCoin(item)],
|
||||
["保险格", readAssetString(item, "season_insurance") || "-"],
|
||||
["体力", readAssetString(item, "stamina_level") || "-"],
|
||||
["负重", readAssetString(item, "load_level") || "-"],
|
||||
["六头", String(getResourceQuantity(item, "helmet6") || "-")],
|
||||
["六甲", String(getResourceQuantity(item, "armor6") || "-")],
|
||||
["AW子弹", String(getResourceQuantity(item, "awmAmmo") || "-")],
|
||||
["绝密KD", String(readAssetNumber(item, "secret_kd") || "0.00")],
|
||||
["比例", formatRatio(item)],
|
||||
["红皮", skinNames.slice(0, 2).join("、") || "-"],
|
||||
["登录方式", item.login_platform || "-"],
|
||||
["IP", regions[0] || item.server_region || "-"],
|
||||
];
|
||||
|
||||
return (
|
||||
<article className="listing-row">
|
||||
<div className="listing-cover">
|
||||
{cover ? <img alt={item.title || code} loading="lazy" src={cover} /> : <span>暂无截图</span>}
|
||||
</div>
|
||||
<div className="listing-main">
|
||||
<div className="listing-head">
|
||||
<div>
|
||||
<Tag color="green">编号</Tag>
|
||||
<strong>{code}</strong>
|
||||
<Button icon={<CopyOutlined />} size="small" type="text" onClick={() => onCopy(item)}>
|
||||
{copied ? "已复制" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<Tag color={item.is_accelerated_sale ? "orange" : "gold"}>{item.is_accelerated_sale ? "爆卖" : item.rank_level || "展示"}</Tag>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
{details.map(([label, value]) => (
|
||||
<div key={`${code}-${label}`} className="detail-item">
|
||||
<span>{label}:</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="listing-meta">
|
||||
{getOnlineTimeText(item) ? <Tag color="cyan">{getOnlineTimeText(item)}</Tag> : null}
|
||||
{fireLevel ? <Tag color="volcano">烽火 {fireLevel}</Tag> : null}
|
||||
{getMainChips(item)
|
||||
.slice(0, 5)
|
||||
.map((chip) => (
|
||||
<Tag key={`${code}-${chip.label}`} className={`mini-tag tag-${chip.tone}`}>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
))}
|
||||
{remark ? <span className="remark">备注:{remark}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="price-line">
|
||||
<span>
|
||||
租金(只含纯币) <strong>¥{Math.round(item.price || 0).toFixed(2)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
押金 <strong>¥{Math.round(item.deposit_amount || 0).toFixed(2)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
合计 <strong>¥{totalPrice.toFixed(2)}</strong>
|
||||
</span>
|
||||
<Button type="link">复制信息</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function buildQuery(nextPage: number, filters: Filters, sort: string): PublicListingQuery {
|
||||
return {
|
||||
page: nextPage,
|
||||
page_size: pageSize,
|
||||
keyword: filters.keyword.trim(),
|
||||
sort: backendSortKey(sort),
|
||||
zone: filters.zones.join(","),
|
||||
login_method: filters.loginMethods.join(","),
|
||||
rank: filters.ranks.join(","),
|
||||
min_coin: toNumber(filters.minCoin),
|
||||
max_coin: toNumber(filters.maxCoin),
|
||||
min_price: toNumber(filters.minPrice),
|
||||
max_price: toNumber(filters.maxPrice),
|
||||
min_secret_kd: toNumber(filters.minKd),
|
||||
max_secret_kd: toNumber(filters.maxKd),
|
||||
stamina: filters.stamina.join(","),
|
||||
load: filters.load.join(","),
|
||||
insurance: filters.insurance.join(","),
|
||||
skin_name: filters.skinNames.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
function backendSortKey(sort: string) {
|
||||
if (sort === "priceAsc" || sort === "priceDesc" || sort === "coinDesc") return sort;
|
||||
return "recommended";
|
||||
}
|
||||
|
||||
function ratioValue(item: Listing) {
|
||||
const configuredRatio = readAssetNumber(item, "publish_ratio");
|
||||
if (configuredRatio > 0) return configuredRatio;
|
||||
if (!item.price) return 0;
|
||||
return Number(item.haf_coin_amount || 0) / 10000 / item.price;
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
if (!value.trim()) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function cloneFilters(filters: Filters): Filters {
|
||||
return {
|
||||
...filters,
|
||||
stamina: [...filters.stamina],
|
||||
load: [...filters.load],
|
||||
insurance: [...filters.insurance],
|
||||
skinNames: [...filters.skinNames],
|
||||
loginMethods: [...filters.loginMethods],
|
||||
ranks: [...filters.ranks],
|
||||
zones: [...filters.zones],
|
||||
};
|
||||
}
|
||||
|
||||
function groupTitle(key: string, title: string) {
|
||||
if (key === "melee") return "刀皮";
|
||||
if (key === "operatorGold") return "干员金皮";
|
||||
if (key === "operatorRed") return "干员红皮";
|
||||
if (key === "weapon") return "枪皮";
|
||||
return title;
|
||||
}
|
||||
|
||||
function insuranceLabel(value: string) {
|
||||
const [rows, cols] = value.split("*").map((item) => Number(item));
|
||||
if (rows > 0 && cols > 0) return `${rows * cols}格`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAssetText(item: Listing, key: string) {
|
||||
const value = item.asset_summary?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
-527
@@ -1,527 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
BadgeCheck,
|
||||
Copy,
|
||||
Filter,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
fetchListingsPage,
|
||||
fetchMobileHomeConfig,
|
||||
type HomeBannerSlide,
|
||||
type Listing,
|
||||
type ListingPublishOptions,
|
||||
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 announcements = ref<string[]>([]);
|
||||
const banners = ref<HomeBannerSlide[]>([]);
|
||||
const publishOptions = ref<ListingPublishOptions>({
|
||||
server_options: [],
|
||||
login_method_options: [],
|
||||
rank_options: [],
|
||||
insurance_options: [],
|
||||
level_options: [],
|
||||
skin_groups: [],
|
||||
});
|
||||
const copiedCode = ref("");
|
||||
const activeSort = ref("recommended");
|
||||
const filterOpen = ref(false);
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
minCoin: "",
|
||||
maxCoin: "",
|
||||
minPrice: "",
|
||||
maxPrice: "",
|
||||
minKd: "",
|
||||
maxKd: "",
|
||||
stamina: [] as string[],
|
||||
load: [] as string[],
|
||||
insurance: [] as string[],
|
||||
skinNames: [] as string[],
|
||||
});
|
||||
|
||||
const sortOptions = [
|
||||
{ key: "recommended", label: "默认", sortable: false },
|
||||
{ key: "price", label: "价格", sortable: true },
|
||||
{ key: "ratio", label: "比例", sortable: true },
|
||||
{ key: "coin", label: "哈夫币", sortable: true },
|
||||
];
|
||||
|
||||
const fallbackLevels = ["3级", "4级", "5级", "6级", "7级"];
|
||||
const fallbackInsurance = ["2*1", "2*2", "2*3", "3*3"];
|
||||
const fallbackSkins = ["信条", "坠星者", "处刑者", "影锋", "暗星", "电锯", "蚀金玫瑰", "维什戴尔", "水墨云图"];
|
||||
|
||||
const levelOptions = computed(() => publishOptions.value.level_options.length ? publishOptions.value.level_options : fallbackLevels);
|
||||
const insuranceOptions = computed(() =>
|
||||
publishOptions.value.insurance_options.length ? publishOptions.value.insurance_options : fallbackInsurance
|
||||
);
|
||||
const skinSections = computed(() => {
|
||||
const groups = publishOptions.value.skin_groups.filter((group) =>
|
||||
["melee", "operatorGold", "operatorRed", "weapon"].includes(group.key)
|
||||
);
|
||||
if (groups.length) {
|
||||
return groups.map((group) => ({
|
||||
key: group.key,
|
||||
title: groupTitle(group.key, group.title),
|
||||
options: group.options,
|
||||
}));
|
||||
}
|
||||
return [{ key: "skin", title: "刀皮", options: fallbackSkins }];
|
||||
});
|
||||
|
||||
const topBanner = computed(() => banners.value[0] || {
|
||||
eyebrow: "三角洲行动账号专区",
|
||||
title: "高哈夫币 · 安全交接 · 随租随玩",
|
||||
badge: "HOT",
|
||||
pill: "租前先核对资料与截图",
|
||||
tone: "blue",
|
||||
});
|
||||
|
||||
const hasMore = computed(() => listings.value.length < total.value);
|
||||
const displayListings = computed(() => {
|
||||
const items = [...listings.value];
|
||||
if (activeSort.value === "ratioAsc") {
|
||||
return items.sort((a, b) => ratioValue(a) - ratioValue(b));
|
||||
}
|
||||
if (activeSort.value === "ratioDesc") {
|
||||
return items.sort((a, b) => ratioValue(b) - ratioValue(a));
|
||||
}
|
||||
if (activeSort.value === "coinAsc") {
|
||||
return items.sort((a, b) => a.haf_coin_amount - b.haf_coin_amount);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
const hasAdvancedFilters = computed(() => {
|
||||
return Boolean(
|
||||
filters.minCoin ||
|
||||
filters.maxCoin ||
|
||||
filters.minPrice ||
|
||||
filters.maxPrice ||
|
||||
filters.minKd ||
|
||||
filters.maxKd ||
|
||||
filters.stamina.length ||
|
||||
filters.load.length ||
|
||||
filters.insurance.length ||
|
||||
filters.skinNames.length
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadHome();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => activeSort.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;
|
||||
publishOptions.value = home.publish_options;
|
||||
} 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;
|
||||
} 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: backendSortKey(),
|
||||
min_coin: toNumber(filters.minCoin),
|
||||
max_coin: toNumber(filters.maxCoin),
|
||||
min_price: toNumber(filters.minPrice),
|
||||
max_price: toNumber(filters.maxPrice),
|
||||
min_secret_kd: toNumber(filters.minKd),
|
||||
max_secret_kd: toNumber(filters.maxKd),
|
||||
stamina: filters.stamina.join(","),
|
||||
load: filters.load.join(","),
|
||||
insurance: filters.insurance.join(","),
|
||||
skin_name: filters.skinNames.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadListings(true);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.minCoin = "";
|
||||
filters.maxCoin = "";
|
||||
filters.minPrice = "";
|
||||
filters.maxPrice = "";
|
||||
filters.minKd = "";
|
||||
filters.maxKd = "";
|
||||
filters.stamina = [];
|
||||
filters.load = [];
|
||||
filters.insurance = [];
|
||||
filters.skinNames = [];
|
||||
activeSort.value = "recommended";
|
||||
loadListings(true);
|
||||
}
|
||||
|
||||
function confirmFilters() {
|
||||
filterOpen.value = false;
|
||||
loadListings(true);
|
||||
}
|
||||
|
||||
function setQuickSort(key: string) {
|
||||
if (key === "recommended") {
|
||||
activeSort.value = "recommended";
|
||||
return;
|
||||
}
|
||||
if (key === "price") {
|
||||
activeSort.value = activeSort.value === "priceAsc" ? "priceDesc" : "priceAsc";
|
||||
return;
|
||||
}
|
||||
if (key === "ratio") {
|
||||
activeSort.value = activeSort.value === "ratioDesc" ? "ratioAsc" : "ratioDesc";
|
||||
return;
|
||||
}
|
||||
if (key === "coin") {
|
||||
activeSort.value = activeSort.value === "coinDesc" ? "coinAsc" : "coinDesc";
|
||||
}
|
||||
}
|
||||
|
||||
function isQuickSortActive(key: string) {
|
||||
if (key === "recommended") return activeSort.value === "recommended";
|
||||
return activeSort.value.startsWith(key);
|
||||
}
|
||||
|
||||
function sortMark(key: string) {
|
||||
if (key === "price") return activeSort.value === "priceAsc" ? "▲" : activeSort.value === "priceDesc" ? "▼" : "↕";
|
||||
if (key === "ratio") return activeSort.value === "ratioAsc" ? "▲" : activeSort.value === "ratioDesc" ? "▼" : "↕";
|
||||
if (key === "coin") return activeSort.value === "coinAsc" ? "▲" : activeSort.value === "coinDesc" ? "▼" : "↕";
|
||||
return "";
|
||||
}
|
||||
|
||||
function backendSortKey() {
|
||||
if (activeSort.value === "priceAsc" || activeSort.value === "priceDesc" || activeSort.value === "coinDesc") {
|
||||
return activeSort.value;
|
||||
}
|
||||
return "recommended";
|
||||
}
|
||||
|
||||
function toggleListValue(list: string[], value: string) {
|
||||
const index = list.indexOf(value);
|
||||
if (index >= 0) {
|
||||
list.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
list.push(value);
|
||||
}
|
||||
|
||||
function isSelected(list: string[], value: string) {
|
||||
return list.includes(value);
|
||||
}
|
||||
|
||||
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 toNumber(value: string) {
|
||||
if (!value.trim()) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function ratioValue(item: Listing) {
|
||||
const configuredRatio = readAssetNumber(item, "publish_ratio");
|
||||
if (configuredRatio > 0) return configuredRatio;
|
||||
if (!item.price) return 0;
|
||||
return Number(item.haf_coin_amount || 0) / 10000 / item.price;
|
||||
}
|
||||
|
||||
function groupTitle(key: string, title: string) {
|
||||
if (key === "melee") return "刀皮";
|
||||
if (key === "operatorGold") return "干员金皮";
|
||||
if (key === "operatorRed") return "干员红皮";
|
||||
if (key === "weapon") return "枪皮";
|
||||
return title;
|
||||
}
|
||||
|
||||
function insuranceLabel(value: string) {
|
||||
const [rows, cols] = value.split("*").map((item) => Number(item));
|
||||
if (rows > 0 && cols > 0) return `${rows * cols}格`;
|
||||
return value;
|
||||
}
|
||||
</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="quick-filter">
|
||||
<div class="sort-line">
|
||||
<button
|
||||
v-for="sort in sortOptions"
|
||||
:key="sort.key"
|
||||
:class="{ active: isQuickSortActive(sort.key) }"
|
||||
type="button"
|
||||
@click="setQuickSort(sort.key)"
|
||||
>
|
||||
{{ sort.label }}
|
||||
<span v-if="sort.sortable" class="sort-mark">{{ sortMark(sort.key) }}</span>
|
||||
</button>
|
||||
<button :class="{ active: filterOpen || hasAdvancedFilters }" type="button" @click="filterOpen = !filterOpen">
|
||||
筛选 <Filter :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="filterOpen" class="filter-overlay" @click.self="filterOpen = false">
|
||||
<section class="filter-panel">
|
||||
<div class="filter-head">
|
||||
<strong>筛选条件</strong>
|
||||
<button type="button" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-body">
|
||||
<label class="filter-field filter-field-wide">
|
||||
<span>账号编号</span>
|
||||
<input v-model="filters.keyword" placeholder="输入编号搜索" @keyup.enter="confirmFilters" />
|
||||
</label>
|
||||
|
||||
<div class="filter-group">
|
||||
<strong>体力等级</strong>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="option in levelOptions"
|
||||
:key="`stamina-${option}`"
|
||||
:class="{ active: isSelected(filters.stamina, option) }"
|
||||
type="button"
|
||||
@click="toggleListValue(filters.stamina, option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<strong>负重等级</strong>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="option in levelOptions"
|
||||
:key="`load-${option}`"
|
||||
:class="{ active: isSelected(filters.load, option) }"
|
||||
type="button"
|
||||
@click="toggleListValue(filters.load, option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<strong>保险格数</strong>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="option in insuranceOptions"
|
||||
:key="`insurance-${option}`"
|
||||
:class="{ active: isSelected(filters.insurance, option) }"
|
||||
type="button"
|
||||
@click="toggleListValue(filters.insurance, option)"
|
||||
>
|
||||
{{ insuranceLabel(option) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="section in skinSections" :key="section.key" class="filter-group filter-group-wide">
|
||||
<strong>{{ section.title }}</strong>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="option in section.options"
|
||||
:key="`${section.key}-${option}`"
|
||||
:class="{ active: isSelected(filters.skinNames, option) }"
|
||||
type="button"
|
||||
@click="toggleListValue(filters.skinNames, option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-range filter-group-wide">
|
||||
<strong>哈夫币范围(M)</strong>
|
||||
<div>
|
||||
<input v-model="filters.minCoin" inputmode="numeric" placeholder="最小" @keyup.enter="confirmFilters" />
|
||||
<span>-</span>
|
||||
<input v-model="filters.maxCoin" inputmode="numeric" placeholder="最大" @keyup.enter="confirmFilters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-range filter-group-wide">
|
||||
<strong>价格范围</strong>
|
||||
<div>
|
||||
<input v-model="filters.minPrice" inputmode="numeric" placeholder="最低¥" @keyup.enter="confirmFilters" />
|
||||
<span>-</span>
|
||||
<input v-model="filters.maxPrice" inputmode="numeric" placeholder="最高¥" @keyup.enter="confirmFilters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-range filter-group-wide">
|
||||
<strong>KD 范围</strong>
|
||||
<div>
|
||||
<input v-model="filters.minKd" inputmode="decimal" placeholder="最小" @keyup.enter="confirmFilters" />
|
||||
<span>-</span>
|
||||
<input v-model="filters.maxKd" inputmode="decimal" placeholder="最大" @keyup.enter="confirmFilters" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="button" @click="filterOpen = false">取消</button>
|
||||
<button type="button" @click="confirmFilters">确定筛选</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<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 displayListings" :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>
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "antd/dist/reset.css";
|
||||
import "./styles.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("app")!).render(<App />);
|
||||
+612
-559
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -10,11 +10,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"references": []
|
||||
}
|
||||
|
||||
+13
-2
@@ -1,12 +1,23 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
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()],
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) return "react-vendor";
|
||||
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) return "antd-vendor";
|
||||
if (id.includes("node_modules")) return "vendor";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user