Files
hfb_show/src/App.tsx
T
yml2213 51991071e7 展示额外消耗品总价与明细悬浮卡片
租金改为「包含额外物品」;按 resources 收费项汇总额外消耗品金额,悬浮展示物品明细。
2026-07-13 23:54:09 +08:00

846 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<ConfigProvider
theme={{
token: {
colorPrimary: "#ff6b00",
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 [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 [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<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) => {
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 (
<main className="show-page">
<header className="site-header">
<div className="nav-inner">
<div className="brand">
<span className="brand-mark"></span>
<div>
<strong>大锤商行</strong>
<span>哈夫租号</span>
</div>
</div>
<nav className="nav-links">
<a className="active">资源号租赁</a>
</nav>
<div className="nav-actions">
<span>已实名认证 · 安全交易</span>
<Button type="primary">24h在线客服</Button>
</div>
</div>
<div className="warning-line" role="note">
禁止未成年人租号、购买游戏账号,严禁任何用户向未成年人出租、出售账号。
</div>
</header>
<section className="search-stage">
<Input.Search
enterButton={<SearchOutlined />}
onChange={(event) => updateFilter("keyword", event.target.value)}
onSearch={() => applyFilters()}
placeholder="请输入编号"
value={filters.keyword}
/>
</section>
{noticeText ? (
<div className="notice-line" role="status" aria-live="polite">
<span className="notice-label">公告</span>
<div className="notice-marquee">
<div className="marquee-track">
<span>{noticeText}</span>
<span aria-hidden="true">{noticeText}</span>
</div>
</div>
</div>
) : null}
<section className="filter-board">
<div className="section-title">
<i />
筛选条件
</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>
<div className="range-inputs">
<Input
inputMode="numeric"
placeholder="最小"
value={filters.minCoin}
onChange={(event) => updateFilter("minCoin", event.target.value)}
/>
<em></em>
<Input
inputMode="numeric"
placeholder="最大"
value={filters.maxCoin}
onChange={(event) => updateFilter("maxCoin", event.target.value)}
/>
</div>
</label>
<label>
<span>KD范围</span>
<div className="range-inputs">
<Input
inputMode="decimal"
placeholder="最小"
value={filters.minKd}
onChange={(event) => updateFilter("minKd", event.target.value)}
/>
<em></em>
<Input
inputMode="decimal"
placeholder="最大"
value={filters.maxKd}
onChange={(event) => updateFilter("maxKd", event.target.value)}
/>
</div>
</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 className="advanced-toggle" type="button" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? "收起高级筛选" : "展开高级筛选"}
<DownOutlined rotate={advancedOpen ? 180 : 0} />
</button>
</div>
</section>
<section className="result-area">
<div className="sort-toolbar">
<div className="sort-segment" role="tablist" aria-label="排序方式">
{sortOptions.map((option) => (
<button
key={option.key}
type="button"
role="tab"
aria-selected={activeSort === option.key}
className={activeSort === option.key ? "active" : ""}
onClick={() => setSort(option.key)}
>
{option.label}
</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}
codeCopied={copiedCode === formatListingCode(item)}
infoCopied={copiedInfoCode === formatListingCode(item)}
item={item}
onCopyCode={copyListingCode}
onCopyInfo={copyListingInfo}
/>
))}
</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">
<div className="footer-inner">
<strong>大锤商行</strong>
<span className="footer-divider" aria-hidden="true">
·
</span>
<span>版权所有 ©2026 大锤网络游戏有限公司</span>
<span className="footer-divider" aria-hidden="true">
·
</span>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">
ICP备2025146668号
</a>
</div>
</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 className="filter-label">{label}</span>
<div className="chip-list">
{options.map((item) => {
const text = render(item);
const selected = active(item);
return (
<button
key={text}
className={selected ? "selected" : ""}
type="button"
aria-pressed={selected}
onClick={() => onSelect(item)}
>
{text}
</button>
);
})}
</div>
</div>
);
}
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 = (
<ResourceTooltipContent consumable={consumable} resources={resources} />
);
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">
<img alt={item.title || code} decoding="async" loading="lazy" src={FIXED_LISTING_COVER} />
</div>
<div className="listing-main">
<div className="listing-head">
<div>
<strong>{item.title || `纯币${formatCoin(item)}资源号`}</strong>
<Tag color="orange">{code}</Tag>
<Button icon={<CopyOutlined />} size="small" type="text" onClick={() => onCopyCode(item)}>
{codeCopied ? "已复制" : "复制"}
</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>
))}
{resources.length ? <Tag color="orange">额外{resources.length}</Tag> : null}
{remark ? <span className="remark">备注:{remark}</span> : null}
</div>
<div className="price-line">
<span>
租金(包含额外物品) <strong>{formatMoney(price)}</strong>
</span>
<Tooltip
arrow={false}
placement="top"
rootClassName="resource-tooltip-root"
styles={{
container: {
padding: 0,
background: "#fff",
color: "#172033",
borderRadius: 12,
boxShadow: "0 12px 32px rgba(21, 32, 43, 0.14)",
},
}}
title={resourceTooltip}
>
<span className="price-hint">
额外消耗品 <strong>{formatMoney(consumable)}</strong>
</span>
</Tooltip>
<span>
租期 <strong className="rental-days">{rentalDuration}</strong>
</span>
<span>
押金 <strong>{formatMoney(deposit)}</strong>
</span>
<span>
合计 <strong>{formatMoney(totalPrice)}</strong>
</span>
<Button type="link" onClick={() => onCopyInfo(item)}>
{infoCopied ? "已复制" : "复制信息"}
</Button>
</div>
</div>
</article>
);
}
function ResourceTooltipContent({
consumable,
resources,
}: {
consumable: number;
resources: ReturnType<typeof getResources>;
}) {
if (!resources.length) {
return <div className="resource-popover empty">暂无额外物品</div>;
}
return (
<div className="resource-popover">
<div className="resource-popover-head">
<div>
<strong>额外物品明细</strong>
<span>{resources.length} </span>
</div>
<em>合计 {formatMoney(consumable)}</em>
</div>
<div className="resource-popover-list">
{resources.map((resource) => (
<div key={resource.key} className="resource-popover-row">
<div className="resource-popover-main">
<span className="resource-name">{resource.label}</span>
<span className="resource-qty">×{resource.quantity}</span>
<span className={resource.mode === "收费" ? "resource-mode paid" : "resource-mode gift"}>
{resource.mode || "-"}
</span>
</div>
<div className="resource-popover-side">
<span className="resource-unit">{resource.price || "-"}</span>
<strong className="resource-amount">
{resource.mode === "收费" && resource.amount > 0 ? `¥${formatMoney(resource.amount)}` : "—"}
</strong>
</div>
</div>
))}
</div>
</div>
);
}
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 : "";
}