diff --git a/src/App.tsx b/src/App.tsx
index a76f9dd..a5622dd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -11,6 +11,7 @@ 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,
@@ -26,12 +27,14 @@ import {
formatListingInfoText,
formatMoney,
formatRatio,
+ getConsumablePrice,
getListingDeposit,
getListingPrice,
getMainChips,
getOnlineTimeText,
getRegions,
getResourceQuantity,
+ getResources,
getSkinNames,
readAssetNumber,
readAssetString,
@@ -618,12 +621,17 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
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") || "-"],
@@ -675,13 +683,33 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
{chip.label}
))}
+ {resources.length ? 额外{resources.length}项 : null}
{remark ? 备注:{remark} : null}
- 租金 ¥{formatMoney(price)}
+ 租金(包含额外物品) ¥{formatMoney(price)}
+
+
+ 额外消耗品 ¥{formatMoney(consumable)}
+
+
租期 {rentalDuration}
@@ -700,6 +728,49 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
);
}
+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,
diff --git a/src/api.ts b/src/api.ts
index b372815..5210562 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -10,6 +10,8 @@ export interface ListingResource {
price: string;
quantity: number;
mode: string;
+ /** 收费项小计(元),赠送为 0 */
+ amount: number;
}
export interface Listing {
diff --git a/src/listingDisplay.ts b/src/listingDisplay.ts
index cf131f0..d9bb691 100644
--- a/src/listingDisplay.ts
+++ b/src/listingDisplay.ts
@@ -73,18 +73,64 @@ export function formatRatio(item: Listing) {
return `1:${formatRatioNumber(coinWan / price)}`;
}
+/** 额外消耗品明细(数量 > 0) */
+export function getResources(item: Listing): ListingResource[] {
+ const resources = item.asset_summary?.resources;
+ if (!Array.isArray(resources)) return [];
+ return resources
+ .filter((resource): resource is Record => typeof resource === "object" && resource !== null)
+ .map((resource) => {
+ const quantity = Number(resource.quantity || 0);
+ const mode = String(resource.mode || "");
+ const price = String(resource.price || "");
+ const amount = mode === "收费" ? roundMoney(quantity * readUnitPrice(price)) : 0;
+ return {
+ key: String(resource.key || ""),
+ label: String(resource.label || ""),
+ price,
+ quantity,
+ mode,
+ amount,
+ };
+ })
+ .filter((resource) => resource.key && resource.label && resource.quantity > 0);
+}
+
+export function getResourceQuantity(item: Listing, key: string) {
+ return getResources(item).find((resource) => resource.key === key)?.quantity || 0;
+}
+
+/** 额外消耗品总价(仅收费项合计,元) */
+export function getConsumablePrice(item: Listing) {
+ return roundMoney(getResources(item).reduce((sum, resource) => sum + resource.amount, 0));
+}
+
+/** 悬浮/复制用的额外物品明细文案 */
+export function formatResourceDetailLines(item: Listing) {
+ return getResources(item).map((resource) => {
+ const unit = resource.price || "-";
+ const mode = resource.mode || "-";
+ if (resource.mode === "收费" && resource.amount > 0) {
+ return `${resource.label} × ${resource.quantity}(${mode}) ${unit} = ¥${formatMoney(resource.amount)}`;
+ }
+ return `${resource.label} × ${resource.quantity}(${mode}) ${unit}`;
+ });
+}
+
/** 复制当前卡片展示的完整信息(非仅编号) */
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 consumable = getConsumablePrice(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 resourceLines = formatResourceDetailLines(item);
const lines = [
`${title} 编号 ${code}`,
@@ -104,7 +150,9 @@ export function formatListingInfoText(item: Listing) {
fireLevel ? `烽火:${fireLevel}` : "",
item.rank_level ? `段位:${item.rank_level}` : "",
remark ? `备注:${remark}` : "",
- `租金:¥${formatMoney(price)}`,
+ `租金(包含额外物品):¥${formatMoney(price)}`,
+ `额外消耗品:¥${formatMoney(consumable)}`,
+ resourceLines.length ? `额外物品明细:\n${resourceLines.map((line) => ` - ${line}`).join("\n")}` : "",
`租期:${formatEstimatedRentalDuration(item)}`,
`押金:¥${formatMoney(deposit)}`,
`合计:¥${formatMoney(total)}`,
@@ -128,25 +176,6 @@ export function readAssetNumber(item: Listing, key: string) {
return 0;
}
-export function getResources(item: Listing): ListingResource[] {
- const resources = item.asset_summary?.resources;
- if (!Array.isArray(resources)) return [];
- return resources
- .filter((resource): resource is Record => typeof resource === "object" && resource !== null)
- .map((resource) => ({
- key: String(resource.key || ""),
- label: String(resource.label || ""),
- price: String(resource.price || ""),
- quantity: Number(resource.quantity || 0),
- mode: String(resource.mode || ""),
- }))
- .filter((resource) => resource.key && resource.label && resource.quantity > 0);
-}
-
-export function getResourceQuantity(item: Listing, key: string) {
- return getResources(item).find((resource) => resource.key === key)?.quantity || 0;
-}
-
export function getSkinNames(item: Listing) {
const skinGroups = item.asset_summary?.skin_groups;
if (typeof skinGroups !== "object" || skinGroups === null) return [];
@@ -188,3 +217,20 @@ function formatRatioNumber(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
+
+function roundMoney(value: number) {
+ return Math.round((Number.isFinite(value) ? value : 0) * 100) / 100;
+}
+
+/** 解析「0.6元/发」「2.5元/个」「3元/张」等单价文案 */
+function readUnitPrice(priceText: string) {
+ const normalized = priceText.replace(/,/g, ",").trim();
+ const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/);
+ if (fractionMatch) {
+ const amount = Number(fractionMatch[1]);
+ const count = Number(fractionMatch[2]);
+ return count > 0 ? amount / count : 0;
+ }
+ const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/);
+ return singleMatch ? Number(singleMatch[1]) : 0;
+}
diff --git a/src/styles.css b/src/styles.css
index a5d15b0..ba87326 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -702,6 +702,166 @@ input {
color: #ff5b66;
}
+.price-hint {
+ cursor: help;
+ border-bottom: 1px dotted #d0d5dd;
+ padding-bottom: 1px;
+ transition: border-color 0.15s ease, color 0.15s ease;
+}
+
+.price-hint:hover {
+ border-bottom-color: var(--brand);
+}
+
+.price-hint:hover strong {
+ color: var(--brand-dark);
+}
+
+/* 额外物品悬浮卡片 */
+.resource-tooltip-root .ant-tooltip-container,
+.resource-tooltip-root .ant-tooltip-inner {
+ width: 340px !important;
+ max-width: min(340px, calc(100vw - 24px)) !important;
+ padding: 0 !important;
+ overflow: hidden;
+ border-radius: 12px !important;
+ border: 1px solid #e8edf3 !important;
+ background: #fff !important;
+ color: var(--text-main) !important;
+ box-shadow: 0 12px 32px rgba(21, 32, 43, 0.14) !important;
+}
+
+.resource-popover {
+ min-width: 0;
+ text-align: left;
+}
+
+.resource-popover.empty {
+ padding: 14px 16px;
+ color: #98a2b3;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.resource-popover-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ background: linear-gradient(180deg, #fff8f1, #fff);
+ border-bottom: 1px solid #f0f2f5;
+}
+
+.resource-popover-head > div {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.resource-popover-head strong {
+ color: var(--text-main);
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.resource-popover-head span {
+ color: #98a2b3;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.resource-popover-head em {
+ flex: none;
+ color: var(--brand);
+ font-size: 13px;
+ font-style: normal;
+ font-weight: 900;
+}
+
+.resource-popover-list {
+ max-height: 280px;
+ overflow: auto;
+ padding: 6px 0;
+}
+
+.resource-popover-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 14px;
+}
+
+.resource-popover-row:hover {
+ background: #fafbfc;
+}
+
+.resource-popover-main {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.resource-name {
+ color: #1f2a37;
+ font-size: 12px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.resource-qty {
+ color: #667085;
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.resource-mode {
+ height: 18px;
+ padding: 0 6px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 18px;
+ white-space: nowrap;
+}
+
+.resource-mode.paid {
+ color: var(--brand-dark);
+ background: var(--brand-soft);
+}
+
+.resource-mode.gift {
+ color: #027a48;
+ background: #ecfdf3;
+}
+
+.resource-popover-side {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+}
+
+.resource-unit {
+ color: #98a2b3;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.resource-amount {
+ color: var(--brand);
+ font-size: 12px;
+ font-weight: 900;
+ white-space: nowrap;
+}
+
.price-line .ant-btn {
margin-left: auto;
padding-right: 0;