846 lines
28 KiB
TypeScript
846 lines
28 KiB
TypeScript
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 : "";
|
||
}
|