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 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 Tooltip from "antd/es/tooltip";
import { useEffect, useMemo, useState } from "react";
import {
fetchListingsPage,
fetchMobileHomeConfig,
type Listing,
type ListingPublishOptions,
type PublicListingQuery,
} from "./api";
import {
formatCoin,
formatEstimatedRentalDuration,
formatListingCode,
formatListingInfoText,
formatMoney,
formatRatio,
getConsumablePrice,
getListingDeposit,
getListingPrice,
getMainChips,
getOnlineTimeText,
getRegions,
getResourceQuantity,
getResources,
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 (
);
}
function Showcase() {
const [listings, setListings] = useState
([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState("");
const [announcements, setAnnouncements] = useState([]);
const [publishOptions, setPublishOptions] = useState(initialOptions);
const [filters, setFilters] = useState(cloneFilters(emptyFilters));
const [appliedFilters, setAppliedFilters] = useState(cloneFilters(emptyFilters));
const [activeSort, setActiveSort] = useState("recommended");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [copiedCode, setCopiedCode] = useState("");
const [copiedInfoCode, setCopiedInfoCode] = 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 noticeText = announcements[0]?.trim() || "";
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);
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(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) => {
if (current[minKey] === min && current[maxKey] === max) {
return { ...current, [minKey]: "", [maxKey]: "" };
}
return { ...current, [minKey]: min, [maxKey]: max };
});
}
function setSort(sortKey: string) {
setActiveSort(sortKey);
setPage(1);
}
async function copyListingCode(item: Listing) {
const code = formatListingCode(item);
try {
await navigator.clipboard?.writeText(code);
setCopiedCode(code);
setCopiedInfoCode("");
window.setTimeout(() => {
setCopiedCode((current) => (current === code ? "" : current));
}, 1500);
} catch {
setCopiedCode("");
}
}
async function copyListingInfo(item: Listing) {
const code = formatListingCode(item);
try {
await navigator.clipboard?.writeText(formatListingInfoText(item));
setCopiedInfoCode(code);
setCopiedCode("");
window.setTimeout(() => {
setCopiedInfoCode((current) => (current === code ? "" : current));
}, 1500);
} catch {
setCopiedInfoCode("");
}
}
return (
}
onChange={(event) => updateFilter("keyword", event.target.value)}
onSearch={() => applyFilters()}
placeholder="请输入编号"
value={filters.keyword}
/>
{noticeText ? (
公告
{noticeText}
{noticeText}
) : null}
筛选条件
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)}
/>
filters.minPrice === item.min && filters.maxPrice === item.max}
label="租金"
options={priceRanges}
render={(item) => item.label}
onSelect={(item) => selectRange("minPrice", "maxPrice", item.min, item.max)}
/>
filters.insurance.includes(item)}
label="保险"
options={insuranceOptions}
render={insuranceLabel}
onSelect={(item) => toggleFilterList("insurance", item)}
/>
filters.stamina.includes(item)}
label="体力"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("stamina", item)}
/>
filters.load.includes(item)}
label="负重"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("load", item)}
/>
IP地址
登录方式
段位
{skinSections.map((section) => (
filters.skinNames.includes(item)}
label={section.title}
options={section.options.slice(0, 18)}
render={(item) => item}
onSelect={(item) => toggleFilterList("skinNames", item)}
/>
))}
{sortOptions.map((option) => (
))}
} onClick={resetFilters}>
重置
} type="primary" onClick={() => applyFilters()}>
搜索
{loadError ? applyFilters()}>重试} /> : null}
{displayListings.length ? (
{displayListings.map((item) => (
))}
) : (
)}
`第 ${page} 页 / 共 ${Math.max(1, Math.ceil(count / pageSize))} 页`}
total={total}
onChange={setPage}
/>
);
}
interface FilterRowProps {
label: string;
options: T[];
active: (item: T) => boolean;
render: (item: T) => string;
onSelect: (item: T) => void;
}
function FilterRow({ active, label, onSelect, options, render }: FilterRowProps) {
return (
{label}
{options.map((item) => {
const text = render(item);
const selected = active(item);
return (
);
})}
);
}
interface ListingRowProps {
item: Listing;
codeCopied: boolean;
infoCopied: boolean;
onCopyCode: (item: Listing) => void;
onCopyInfo: (item: Listing) => void;
}
const FIXED_LISTING_COVER = "/listing-cover.jpg";
function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: ListingRowProps) {
const code = formatListingCode(item);
const price = getListingPrice(item);
const deposit = getListingDeposit(item);
const consumable = getConsumablePrice(item);
const totalPrice = Math.round((price + deposit) * 100) / 100;
const rentalDuration = formatEstimatedRentalDuration(item);
const remark = getAssetText(item, "remark");
const regions = getRegions(item);
const skinNames = getSkinNames(item);
const fireLevel = readAssetNumber(item, "fire_level");
const resources = getResources(item);
const resourceTooltip = (
);
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 (
{item.title || `纯币${formatCoin(item)}资源号`}
{code}
} size="small" type="text" onClick={() => onCopyCode(item)}>
{codeCopied ? "已复制" : "复制"}
{item.is_accelerated_sale ? "爆卖" : item.rank_level || "展示"}
{details.map(([label, value]) => (
{label}:
{value}
))}
{getOnlineTimeText(item) ? {getOnlineTimeText(item)} : null}
{fireLevel ? 烽火 {fireLevel} : null}
{getMainChips(item)
.slice(0, 5)
.map((chip) => (
{chip.label}
))}
{resources.length ? 额外{resources.length}项 : null}
{remark ? 备注:{remark} : null}
租金(包含额外物品) ¥{formatMoney(price)}
额外消耗品 ¥{formatMoney(consumable)}
租期 {rentalDuration}
押金 ¥{formatMoney(deposit)}
合计 ¥{formatMoney(totalPrice)}
);
}
function ResourceTooltipContent({
consumable,
resources,
}: {
consumable: number;
resources: ReturnType;
}) {
if (!resources.length) {
return 暂无额外物品
;
}
return (
额外物品明细
{resources.length} 项
合计 ¥{formatMoney(consumable)}
{resources.map((resource) => (
{resource.label}
×{resource.quantity}
{resource.mode || "-"}
{resource.price || "-"}
{resource.mode === "收费" && resource.amount > 0 ? `¥${formatMoney(resource.amount)}` : "—"}
))}
);
}
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;
const price = getListingPrice(item);
if (!price) return 0;
return Number(item.haf_coin_amount || 0) / 10000 / 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 : "";
}