diff --git a/public/listing-cover.jpg b/public/listing-cover.jpg new file mode 100644 index 0000000..6923e93 Binary files /dev/null and b/public/listing-cover.jpg differ diff --git a/src/App.tsx b/src/App.tsx index cf923ca..4a4bc2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,20 +11,23 @@ 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, + formatEstimatedRentalDuration, formatListingCode, + formatListingInfoText, + formatMoney, formatRatio, + getListingDeposit, + getListingPrice, getMainChips, getOnlineTimeText, getRegions, @@ -148,13 +151,13 @@ function Showcase() { const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(""); const [announcements, setAnnouncements] = useState([]); - const [banners, setBanners] = 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), @@ -187,17 +190,7 @@ function Showcase() { return [{ key: "skin", title: "稀有皮肤", options: fallbackSkins }]; }, [publishOptions.skin_groups]); - const topBanner = useMemo( - () => - banners[0] || { - eyebrow: "大锤商行 · 资源号租赁", - title: "哈夫币资源号实时租赁", - badge: "24H", - pill: "安全交易 · 诚信服务 · 专业保障", - tone: "orange", - }, - [banners] - ); + const noticeText = announcements[0]?.trim() || ""; const displayListings = useMemo(() => { const items = [...listings]; @@ -216,7 +209,6 @@ function Showcase() { .then((home) => { if (disposed) return; setAnnouncements(home.announcements); - setBanners(home.banners); setPublishOptions(home.publish_options); }) .catch(() => { @@ -284,12 +276,12 @@ function Showcase() { setPage(1); } - async function copyListing(item: Listing) { + async function copyListingCode(item: Listing) { const code = formatListingCode(item); - const text = `${code} 纯币 ${formatCoin(item)} ¥${item.price} 比例 ${formatRatio(item)}`; try { - await navigator.clipboard?.writeText(text); + await navigator.clipboard?.writeText(code); setCopiedCode(code); + setCopiedInfoCode(""); window.setTimeout(() => { setCopiedCode((current) => (current === code ? "" : current)); }, 1500); @@ -298,11 +290,19 @@ function Showcase() { } } - const heroStyle: CSSProperties | undefined = topBanner.image_url - ? { - backgroundImage: `linear-gradient(90deg, rgba(255, 246, 237, .94), rgba(255, 246, 237, .72)), url(${topBanner.image_url})`, - } - : undefined; + 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 (
@@ -323,23 +323,11 @@ function Showcase() { -
-
- 禁止未成年人租号、购买游戏账号,严禁任何用户向未成年人出租、出售账号。 - -
+
+ 禁止未成年人租号、购买游戏账号,严禁任何用户向未成年人出租、出售账号。
-
-
- {topBanner.eyebrow || "大锤商行 · 资源号租赁"} - {topBanner.title || "哈夫币资源号实时租赁"} -
- {topBanner.badge || "HOT"} -

{topBanner.pill || "安全交易 · 诚信服务 · 专业保障"}

-
-
} @@ -350,13 +338,13 @@ function Showcase() { />
- {announcements.length ? ( + {noticeText ? (
公告
- {announcements.join(" | ")} - + {noticeText} +
@@ -515,7 +503,14 @@ function Showcase() { {displayListings.length ? (
{displayListings.map((item) => ( - + ))}
) : ( @@ -582,14 +577,20 @@ function FilterRow({ active, label, onSelect, options, render }: FilterRowPro interface ListingRowProps { item: Listing; - copied: boolean; - onCopy: (item: Listing) => void; + codeCopied: boolean; + 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 cover = item.cover_url || item.screenshot_urls?.[0] || ""; - const totalPrice = Math.round(Number(item.price || 0) + Number(item.deposit_amount || 0)); + const price = getListingPrice(item); + const deposit = getListingDeposit(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); @@ -612,15 +613,15 @@ function ListingRow({ copied, item, onCopy }: ListingRowProps) { return (
- {cover ? {item.title : 暂无截图} + {item.title
{item.title || `纯币${formatCoin(item)}资源号`} {code} -
{item.is_accelerated_sale ? "爆卖" : item.rank_level || "展示"} @@ -650,15 +651,20 @@ function ListingRow({ copied, item, onCopy }: ListingRowProps) {
- 租金(只含纯币) ¥{Math.round(item.price || 0).toFixed(2)} + 租金 ¥{formatMoney(price)} - 押金 ¥{Math.round(item.deposit_amount || 0).toFixed(2)} + 租期 {rentalDuration} - 合计 ¥{totalPrice.toFixed(2)} + 押金 ¥{formatMoney(deposit)} - + + 合计 ¥{formatMoney(totalPrice)} + +
@@ -695,8 +701,9 @@ function backendSortKey(sort: string) { 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; + const price = getListingPrice(item); + if (!price) return 0; + return Number(item.haf_coin_amount || 0) / 10000 / price; } function toNumber(value: string) { diff --git a/src/api.ts b/src/api.ts index ac4c110..b372815 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,7 @@ export interface ListingResource { export interface Listing { id: number; + listing_no?: string; title: string; description: string; game_name: string; @@ -24,8 +25,12 @@ export interface Listing { asset_summary?: Record; screenshot_urls: string[]; cover_url: string; - price: number; - deposit_amount: number; + price_cent: number; + deposit_amount_cent: number; + /** @deprecated 兼容旧字段,优先使用 price_cent */ + price?: number; + /** @deprecated 兼容旧字段,优先使用 deposit_amount_cent */ + deposit_amount?: number; is_accelerated_sale?: boolean; in_transaction: boolean; published_at?: string; diff --git a/src/listingDisplay.ts b/src/listingDisplay.ts index 83800b0..cf131f0 100644 --- a/src/listingDisplay.ts +++ b/src/listingDisplay.ts @@ -1,21 +1,116 @@ import type { Listing, ListingResource } from "./api"; 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) { - const coinM = Number(item.haf_coin_amount || 0) / 1_000_000; + const coinM = getCoinM(item); const rounded = Math.round(coinM * 10) / 10; 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) { const configuredRatio = readAssetNumber(item, "publish_ratio"); 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; - 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) { diff --git a/src/styles.css b/src/styles.css index 7d8fb37..852df82 100644 --- a/src/styles.css +++ b/src/styles.css @@ -151,23 +151,20 @@ input { } .warning-line { - height: 28px; + min-height: 28px; + padding: 6px 16px; display: flex; align-items: center; - overflow: hidden; + justify-content: center; color: #b95600; - background: linear-gradient(90deg, #fff4e8, #fff8f0 40%, #fff4e8); + background: #fff7ef; border-bottom: 1px solid #ffe0c2; font-size: 12px; 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, .filter-board, .result-area { @@ -176,51 +173,10 @@ input { 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 { display: flex; justify-content: center; - padding: 16px 0 22px; + padding: 18px 0 14px; } .search-stage .ant-input-search { @@ -240,7 +196,7 @@ input { .notice-line { width: min(1480px, calc(100% - 160px)); height: 34px; - margin: -8px auto 12px; + margin: 0 auto 12px; padding: 0 12px 0 10px; display: flex; align-items: center; @@ -628,15 +584,23 @@ input { font-weight: 900; } +.price-line .rental-days { + color: #ff5b66; +} + .price-line .ant-btn { margin-left: auto; + padding-right: 0; + color: #023f34; font-weight: 900; } -.price-line .ant-btn-primary { - min-width: 112px; - color: #fff; - box-shadow: 0 8px 18px rgba(255, 107, 0, 0.18); +.price-line .ant-btn-link { + color: var(--brand-dark); +} + +.price-line .ant-btn-link:hover { + color: var(--brand); } .empty-state { @@ -693,7 +657,6 @@ input { @media (max-width: 1280px) { .nav-inner, - .hero-strip, .search-stage, .filter-board, .result-area, @@ -732,7 +695,6 @@ input { overflow-x: auto; } - .hero-strip, .search-stage, .filter-board, .result-area, @@ -740,14 +702,6 @@ input { width: calc(100% - 24px); } - .hero-strip { - grid-template-columns: 1fr; - } - - .hero-strip p { - white-space: normal; - } - .filter-grid, .range-fields { grid-template-columns: 1fr; @@ -778,10 +732,6 @@ input { width: calc(100% - 24px); } - .warning-line .marquee-track { - animation-duration: 20s; - } - .notice-marquee .marquee-track { animation-duration: 16s; }