优化商品展示:固定封面、正确编号与价格、租期与复制信息

使用固定商品图避免远程图加载慢;编号改为 listing_no;价格按 price_cent 换算;去掉 Hero 横幅;公告仅一条滚动;展示租期;区分复制编号与复制完整信息。
This commit is contained in:
yml2213
2026-07-13 23:37:26 +08:00
parent b82d7c4d1d
commit e73e766b56
5 changed files with 188 additions and 131 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+61 -54
View File
@@ -11,20 +11,23 @@ import Pagination from "antd/es/pagination";
import Select from "antd/es/select"; import Select from "antd/es/select";
import Spin from "antd/es/spin"; import Spin from "antd/es/spin";
import Tag from "antd/es/tag"; import Tag from "antd/es/tag";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
fetchListingsPage, fetchListingsPage,
fetchMobileHomeConfig, fetchMobileHomeConfig,
type HomeBannerSlide,
type Listing, type Listing,
type ListingPublishOptions, type ListingPublishOptions,
type PublicListingQuery, type PublicListingQuery,
} from "./api"; } from "./api";
import { import {
formatCoin, formatCoin,
formatEstimatedRentalDuration,
formatListingCode, formatListingCode,
formatListingInfoText,
formatMoney,
formatRatio, formatRatio,
getListingDeposit,
getListingPrice,
getMainChips, getMainChips,
getOnlineTimeText, getOnlineTimeText,
getRegions, getRegions,
@@ -148,13 +151,13 @@ function Showcase() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
const [announcements, setAnnouncements] = useState<string[]>([]); const [announcements, setAnnouncements] = useState<string[]>([]);
const [banners, setBanners] = useState<HomeBannerSlide[]>([]);
const [publishOptions, setPublishOptions] = useState<ListingPublishOptions>(initialOptions); const [publishOptions, setPublishOptions] = useState<ListingPublishOptions>(initialOptions);
const [filters, setFilters] = useState<Filters>(cloneFilters(emptyFilters)); const [filters, setFilters] = useState<Filters>(cloneFilters(emptyFilters));
const [appliedFilters, setAppliedFilters] = useState<Filters>(cloneFilters(emptyFilters)); const [appliedFilters, setAppliedFilters] = useState<Filters>(cloneFilters(emptyFilters));
const [activeSort, setActiveSort] = useState("recommended"); const [activeSort, setActiveSort] = useState("recommended");
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [copiedCode, setCopiedCode] = useState(""); const [copiedCode, setCopiedCode] = useState("");
const [copiedInfoCode, setCopiedInfoCode] = useState("");
const levelOptions = useMemo( const levelOptions = useMemo(
() => (publishOptions.level_options.length ? publishOptions.level_options : fallbackLevels), () => (publishOptions.level_options.length ? publishOptions.level_options : fallbackLevels),
@@ -187,17 +190,7 @@ function Showcase() {
return [{ key: "skin", title: "稀有皮肤", options: fallbackSkins }]; return [{ key: "skin", title: "稀有皮肤", options: fallbackSkins }];
}, [publishOptions.skin_groups]); }, [publishOptions.skin_groups]);
const topBanner = useMemo( const noticeText = announcements[0]?.trim() || "";
() =>
banners[0] || {
eyebrow: "大锤商行 · 资源号租赁",
title: "哈夫币资源号实时租赁",
badge: "24H",
pill: "安全交易 · 诚信服务 · 专业保障",
tone: "orange",
},
[banners]
);
const displayListings = useMemo(() => { const displayListings = useMemo(() => {
const items = [...listings]; const items = [...listings];
@@ -216,7 +209,6 @@ function Showcase() {
.then((home) => { .then((home) => {
if (disposed) return; if (disposed) return;
setAnnouncements(home.announcements); setAnnouncements(home.announcements);
setBanners(home.banners);
setPublishOptions(home.publish_options); setPublishOptions(home.publish_options);
}) })
.catch(() => { .catch(() => {
@@ -284,12 +276,12 @@ function Showcase() {
setPage(1); setPage(1);
} }
async function copyListing(item: Listing) { async function copyListingCode(item: Listing) {
const code = formatListingCode(item); const code = formatListingCode(item);
const text = `${code} 纯币 ${formatCoin(item)}${item.price} 比例 ${formatRatio(item)}`;
try { try {
await navigator.clipboard?.writeText(text); await navigator.clipboard?.writeText(code);
setCopiedCode(code); setCopiedCode(code);
setCopiedInfoCode("");
window.setTimeout(() => { window.setTimeout(() => {
setCopiedCode((current) => (current === code ? "" : current)); setCopiedCode((current) => (current === code ? "" : current));
}, 1500); }, 1500);
@@ -298,11 +290,19 @@ function Showcase() {
} }
} }
const heroStyle: CSSProperties | undefined = topBanner.image_url async function copyListingInfo(item: Listing) {
? { const code = formatListingCode(item);
backgroundImage: `linear-gradient(90deg, rgba(255, 246, 237, .94), rgba(255, 246, 237, .72)), url(${topBanner.image_url})`, try {
await navigator.clipboard?.writeText(formatListingInfoText(item));
setCopiedInfoCode(code);
setCopiedCode("");
window.setTimeout(() => {
setCopiedInfoCode((current) => (current === code ? "" : current));
}, 1500);
} catch {
setCopiedInfoCode("");
}
} }
: undefined;
return ( return (
<main className="show-page"> <main className="show-page">
@@ -323,23 +323,11 @@ function Showcase() {
<Button type="primary">24h在线客服</Button> <Button type="primary">24h在线客服</Button>
</div> </div>
</div> </div>
<div className="warning-line" role="note" aria-label="未成年人保护提示"> <div className="warning-line" role="note">
<div className="marquee-track">
<span></span>
<span aria-hidden="true"></span>
</div>
</div> </div>
</header> </header>
<section className="hero-strip" style={heroStyle}>
<div>
<span>{topBanner.eyebrow || "大锤商行 · 资源号租赁"}</span>
<strong>{topBanner.title || "哈夫币资源号实时租赁"}</strong>
</div>
<Tag color="orange">{topBanner.badge || "HOT"}</Tag>
<p>{topBanner.pill || "安全交易 · 诚信服务 · 专业保障"}</p>
</section>
<section className="search-stage"> <section className="search-stage">
<Input.Search <Input.Search
enterButton={<SearchOutlined />} enterButton={<SearchOutlined />}
@@ -350,13 +338,13 @@ function Showcase() {
/> />
</section> </section>
{announcements.length ? ( {noticeText ? (
<div className="notice-line" role="status" aria-live="polite"> <div className="notice-line" role="status" aria-live="polite">
<span className="notice-label"></span> <span className="notice-label"></span>
<div className="notice-marquee"> <div className="notice-marquee">
<div className="marquee-track"> <div className="marquee-track">
<span>{announcements.join("  ")}</span> <span>{noticeText}</span>
<span aria-hidden="true">{announcements.join("  ")}</span> <span aria-hidden="true">{noticeText}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -515,7 +503,14 @@ function Showcase() {
{displayListings.length ? ( {displayListings.length ? (
<div className="listing-list"> <div className="listing-list">
{displayListings.map((item) => ( {displayListings.map((item) => (
<ListingRow key={item.id} copied={copiedCode === formatListingCode(item)} item={item} onCopy={copyListing} /> <ListingRow
key={item.id}
codeCopied={copiedCode === formatListingCode(item)}
infoCopied={copiedInfoCode === formatListingCode(item)}
item={item}
onCopyCode={copyListingCode}
onCopyInfo={copyListingInfo}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -582,14 +577,20 @@ function FilterRow<T>({ active, label, onSelect, options, render }: FilterRowPro
interface ListingRowProps { interface ListingRowProps {
item: Listing; item: Listing;
copied: boolean; codeCopied: boolean;
onCopy: (item: Listing) => void; infoCopied: boolean;
onCopyCode: (item: Listing) => void;
onCopyInfo: (item: Listing) => void;
} }
function ListingRow({ copied, item, onCopy }: ListingRowProps) { const FIXED_LISTING_COVER = "/listing-cover.jpg";
function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: ListingRowProps) {
const code = formatListingCode(item); const code = formatListingCode(item);
const cover = item.cover_url || item.screenshot_urls?.[0] || ""; const price = getListingPrice(item);
const totalPrice = Math.round(Number(item.price || 0) + Number(item.deposit_amount || 0)); const deposit = getListingDeposit(item);
const totalPrice = Math.round((price + deposit) * 100) / 100;
const rentalDuration = formatEstimatedRentalDuration(item);
const remark = getAssetText(item, "remark"); const remark = getAssetText(item, "remark");
const regions = getRegions(item); const regions = getRegions(item);
const skinNames = getSkinNames(item); const skinNames = getSkinNames(item);
@@ -612,15 +613,15 @@ function ListingRow({ copied, item, onCopy }: ListingRowProps) {
return ( return (
<article className="listing-row"> <article className="listing-row">
<div className="listing-cover"> <div className="listing-cover">
{cover ? <img alt={item.title || code} loading="lazy" src={cover} /> : <span></span>} <img alt={item.title || code} decoding="async" loading="lazy" src={FIXED_LISTING_COVER} />
</div> </div>
<div className="listing-main"> <div className="listing-main">
<div className="listing-head"> <div className="listing-head">
<div> <div>
<strong>{item.title || `纯币${formatCoin(item)}资源号`}</strong> <strong>{item.title || `纯币${formatCoin(item)}资源号`}</strong>
<Tag color="orange">{code}</Tag> <Tag color="orange">{code}</Tag>
<Button icon={<CopyOutlined />} size="small" type="text" onClick={() => onCopy(item)}> <Button icon={<CopyOutlined />} size="small" type="text" onClick={() => onCopyCode(item)}>
{copied ? "已复制" : ""} {codeCopied ? "已复制" : "复制"}
</Button> </Button>
</div> </div>
<Tag color={item.is_accelerated_sale ? "orange" : "gold"}>{item.is_accelerated_sale ? "爆卖" : item.rank_level || "展示"}</Tag> <Tag color={item.is_accelerated_sale ? "orange" : "gold"}>{item.is_accelerated_sale ? "爆卖" : item.rank_level || "展示"}</Tag>
@@ -650,15 +651,20 @@ function ListingRow({ copied, item, onCopy }: ListingRowProps) {
<div className="price-line"> <div className="price-line">
<span> <span>
() <strong>{Math.round(item.price || 0).toFixed(2)}</strong> <strong>{formatMoney(price)}</strong>
</span> </span>
<span> <span>
<strong>{Math.round(item.deposit_amount || 0).toFixed(2)}</strong> <strong className="rental-days">{rentalDuration}</strong>
</span> </span>
<span> <span>
<strong>{totalPrice.toFixed(2)}</strong> <strong>{formatMoney(deposit)}</strong>
</span> </span>
<Button type="primary"></Button> <span>
<strong>{formatMoney(totalPrice)}</strong>
</span>
<Button type="link" onClick={() => onCopyInfo(item)}>
{infoCopied ? "已复制" : "复制信息"}
</Button>
</div> </div>
</div> </div>
</article> </article>
@@ -695,8 +701,9 @@ function backendSortKey(sort: string) {
function ratioValue(item: Listing) { function ratioValue(item: Listing) {
const configuredRatio = readAssetNumber(item, "publish_ratio"); const configuredRatio = readAssetNumber(item, "publish_ratio");
if (configuredRatio > 0) return configuredRatio; if (configuredRatio > 0) return configuredRatio;
if (!item.price) return 0; const price = getListingPrice(item);
return Number(item.haf_coin_amount || 0) / 10000 / item.price; if (!price) return 0;
return Number(item.haf_coin_amount || 0) / 10000 / price;
} }
function toNumber(value: string) { function toNumber(value: string) {
+7 -2
View File
@@ -14,6 +14,7 @@ export interface ListingResource {
export interface Listing { export interface Listing {
id: number; id: number;
listing_no?: string;
title: string; title: string;
description: string; description: string;
game_name: string; game_name: string;
@@ -24,8 +25,12 @@ export interface Listing {
asset_summary?: Record<string, unknown>; asset_summary?: Record<string, unknown>;
screenshot_urls: string[]; screenshot_urls: string[];
cover_url: string; cover_url: string;
price: number; price_cent: number;
deposit_amount: number; deposit_amount_cent: number;
/** @deprecated 兼容旧字段,优先使用 price_cent */
price?: number;
/** @deprecated 兼容旧字段,优先使用 deposit_amount_cent */
deposit_amount?: number;
is_accelerated_sale?: boolean; is_accelerated_sale?: boolean;
in_transaction: boolean; in_transaction: boolean;
published_at?: string; published_at?: string;
+99 -4
View File
@@ -1,21 +1,116 @@
import type { Listing, ListingResource } from "./api"; import type { Listing, ListingResource } from "./api";
export function formatListingCode(item: Listing) { export function formatListingCode(item: Listing) {
return `SP${String(item.id).padStart(4, "0")}`; if (item.listing_no?.trim()) return item.listing_no.trim();
return String(item.id || "-");
}
export function centToYuan(cent: number | undefined | null) {
const value = Number(cent || 0);
if (!Number.isFinite(value)) return 0;
return Math.round(value) / 100;
}
/** 租金(元),兼容 price_cent 与旧字段 price */
export function getListingPrice(item: Listing) {
if (item.price_cent !== undefined && item.price_cent !== null) {
return centToYuan(item.price_cent);
}
const legacy = Number(item.price || 0);
return Number.isFinite(legacy) ? legacy : 0;
}
/** 押金(元),兼容 deposit_amount_cent 与旧字段 deposit_amount */
export function getListingDeposit(item: Listing) {
if (item.deposit_amount_cent !== undefined && item.deposit_amount_cent !== null) {
return centToYuan(item.deposit_amount_cent);
}
const legacy = Number(item.deposit_amount || 0);
return Number.isFinite(legacy) ? legacy : 0;
}
export function formatMoney(value: number) {
const amount = Number.isFinite(value) ? value : 0;
return amount.toFixed(2);
}
export function getCoinM(item: Listing) {
return Number(item.haf_coin_amount || 0) / 1_000_000;
} }
export function formatCoin(item: Listing) { export function formatCoin(item: Listing) {
const coinM = Number(item.haf_coin_amount || 0) / 1_000_000; const coinM = getCoinM(item);
const rounded = Math.round(coinM * 10) / 10; const rounded = Math.round(coinM * 10) / 10;
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`; return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`;
} }
/** 日耗(M),来自 asset_summary.daily_loss_m */
export function getDailyLossM(item: Listing) {
const configuredLoss = readAssetNumber(item, "daily_loss_m");
return configuredLoss > 0 ? configuredLoss : 0;
}
/** 预计租期(天)= 哈夫币M / 日耗M */
export function getEstimatedRentalDays(item: Listing) {
const coinM = getCoinM(item);
const dailyLossM = getDailyLossM(item);
if (coinM <= 0 || dailyLossM <= 0) return 0;
return coinM / dailyLossM;
}
export function formatEstimatedRentalDuration(item: Listing) {
const days = getEstimatedRentalDays(item);
if (days <= 0) return "--";
return `${Math.max(1, Math.round(days))}`;
}
export function formatRatio(item: Listing) { export function formatRatio(item: Listing) {
const configuredRatio = readAssetNumber(item, "publish_ratio"); const configuredRatio = readAssetNumber(item, "publish_ratio");
if (configuredRatio > 0) return `1:${formatRatioNumber(configuredRatio)}`; if (configuredRatio > 0) return `1:${formatRatioNumber(configuredRatio)}`;
if (!item.price) return "--"; const price = getListingPrice(item);
if (!price) return "--";
const coinWan = Number(item.haf_coin_amount || 0) / 10000; const coinWan = Number(item.haf_coin_amount || 0) / 10000;
return `1:${formatRatioNumber(coinWan / item.price)}`; return `1:${formatRatioNumber(coinWan / price)}`;
}
/** 复制当前卡片展示的完整信息(非仅编号) */
export function formatListingInfoText(item: Listing) {
const code = formatListingCode(item);
const title = item.title?.trim() || `纯币${formatCoin(item)}资源号`;
const price = getListingPrice(item);
const deposit = getListingDeposit(item);
const total = Math.round((price + deposit) * 100) / 100;
const regions = getRegions(item);
const skinNames = getSkinNames(item);
const remark = typeof item.asset_summary?.remark === "string" ? item.asset_summary.remark : "";
const online = getOnlineTimeText(item);
const fireLevel = readAssetNumber(item, "fire_level");
const lines = [
`${title} 编号 ${code}`,
`哈夫币:${formatCoin(item)}`,
`保险格:${readAssetString(item, "season_insurance") || "-"}`,
`体力:${readAssetString(item, "stamina_level") || "-"}`,
`负重:${readAssetString(item, "load_level") || "-"}`,
`六头:${getResourceQuantity(item, "helmet6") || "-"}`,
`六甲:${getResourceQuantity(item, "armor6") || "-"}`,
`AW子弹:${getResourceQuantity(item, "awmAmmo") || "-"}`,
`绝密KD${readAssetNumber(item, "secret_kd") || "0.00"}`,
`比例:${formatRatio(item)}`,
`红皮:${skinNames.slice(0, 2).join("、") || "-"}`,
`登录方式:${item.login_platform || "-"}`,
`IP${regions[0] || item.server_region || "-"}`,
online ? `上号时间:${online}` : "",
fireLevel ? `烽火:${fireLevel}` : "",
item.rank_level ? `段位:${item.rank_level}` : "",
remark ? `备注:${remark}` : "",
`租金:¥${formatMoney(price)}`,
`租期:${formatEstimatedRentalDuration(item)}`,
`押金:¥${formatMoney(deposit)}`,
`合计:¥${formatMoney(total)}`,
];
return lines.filter(Boolean).join("\n");
} }
export function readAssetString(item: Listing, key: string) { export function readAssetString(item: Listing, key: string) {
+20 -70
View File
@@ -151,23 +151,20 @@ input {
} }
.warning-line { .warning-line {
height: 28px; min-height: 28px;
padding: 6px 16px;
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; justify-content: center;
color: #b95600; color: #b95600;
background: linear-gradient(90deg, #fff4e8, #fff8f0 40%, #fff4e8); background: #fff7ef;
border-bottom: 1px solid #ffe0c2; border-bottom: 1px solid #ffe0c2;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; text-align: center;
line-height: 1.4;
} }
.warning-line .marquee-track {
animation-duration: 28s;
}
.hero-strip,
.search-stage, .search-stage,
.filter-board, .filter-board,
.result-area { .result-area {
@@ -176,51 +173,10 @@ input {
margin-left: auto; margin-left: auto;
} }
.hero-strip {
min-height: 84px;
margin-top: 18px;
padding: 16px 22px;
display: grid;
grid-template-columns: 1fr max-content max-content;
align-items: center;
gap: 18px;
color: var(--text-main);
background:
linear-gradient(90deg, rgba(255, 248, 241, 0.98), rgba(255, 241, 225, 0.9)),
linear-gradient(135deg, #fff, #fff1e3);
background-size: cover;
background-position: center;
border: 1px solid var(--brand-line);
border-left: 4px solid var(--brand);
border-radius: 0 0 14px 14px;
box-shadow: 0 10px 26px rgba(255, 107, 0, 0.08);
}
.hero-strip span {
display: block;
color: var(--brand);
font-size: 12px;
font-weight: 700;
}
.hero-strip strong {
display: block;
margin-top: 3px;
font-size: 22px;
letter-spacing: 0;
}
.hero-strip p {
margin: 0;
color: #7a5a43;
font-size: 13px;
white-space: nowrap;
}
.search-stage { .search-stage {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 16px 0 22px; padding: 18px 0 14px;
} }
.search-stage .ant-input-search { .search-stage .ant-input-search {
@@ -240,7 +196,7 @@ input {
.notice-line { .notice-line {
width: min(1480px, calc(100% - 160px)); width: min(1480px, calc(100% - 160px));
height: 34px; height: 34px;
margin: -8px auto 12px; margin: 0 auto 12px;
padding: 0 12px 0 10px; padding: 0 12px 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -628,15 +584,23 @@ input {
font-weight: 900; font-weight: 900;
} }
.price-line .rental-days {
color: #ff5b66;
}
.price-line .ant-btn { .price-line .ant-btn {
margin-left: auto; margin-left: auto;
padding-right: 0;
color: #023f34;
font-weight: 900; font-weight: 900;
} }
.price-line .ant-btn-primary { .price-line .ant-btn-link {
min-width: 112px; color: var(--brand-dark);
color: #fff; }
box-shadow: 0 8px 18px rgba(255, 107, 0, 0.18);
.price-line .ant-btn-link:hover {
color: var(--brand);
} }
.empty-state { .empty-state {
@@ -693,7 +657,6 @@ input {
@media (max-width: 1280px) { @media (max-width: 1280px) {
.nav-inner, .nav-inner,
.hero-strip,
.search-stage, .search-stage,
.filter-board, .filter-board,
.result-area, .result-area,
@@ -732,7 +695,6 @@ input {
overflow-x: auto; overflow-x: auto;
} }
.hero-strip,
.search-stage, .search-stage,
.filter-board, .filter-board,
.result-area, .result-area,
@@ -740,14 +702,6 @@ input {
width: calc(100% - 24px); width: calc(100% - 24px);
} }
.hero-strip {
grid-template-columns: 1fr;
}
.hero-strip p {
white-space: normal;
}
.filter-grid, .filter-grid,
.range-fields { .range-fields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -778,10 +732,6 @@ input {
width: calc(100% - 24px); width: calc(100% - 24px);
} }
.warning-line .marquee-track {
animation-duration: 20s;
}
.notice-marquee .marquee-track { .notice-marquee .marquee-track {
animation-duration: 16s; animation-duration: 16s;
} }