展示额外消耗品总价与明细悬浮卡片

租金改为「包含额外物品」;按 resources 收费项汇总额外消耗品金额,悬浮展示物品明细。
This commit is contained in:
yml2213
2026-07-13 23:54:09 +08:00
parent 35b0278524
commit 51991071e7
4 changed files with 300 additions and 21 deletions
+72 -1
View File
@@ -11,6 +11,7 @@ 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 Tooltip from "antd/es/tooltip";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
fetchListingsPage, fetchListingsPage,
@@ -26,12 +27,14 @@ import {
formatListingInfoText, formatListingInfoText,
formatMoney, formatMoney,
formatRatio, formatRatio,
getConsumablePrice,
getListingDeposit, getListingDeposit,
getListingPrice, getListingPrice,
getMainChips, getMainChips,
getOnlineTimeText, getOnlineTimeText,
getRegions, getRegions,
getResourceQuantity, getResourceQuantity,
getResources,
getSkinNames, getSkinNames,
readAssetNumber, readAssetNumber,
readAssetString, readAssetString,
@@ -618,12 +621,17 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
const code = formatListingCode(item); const code = formatListingCode(item);
const price = getListingPrice(item); const price = getListingPrice(item);
const deposit = getListingDeposit(item); const deposit = getListingDeposit(item);
const consumable = getConsumablePrice(item);
const totalPrice = Math.round((price + deposit) * 100) / 100; const totalPrice = Math.round((price + deposit) * 100) / 100;
const rentalDuration = formatEstimatedRentalDuration(item); 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);
const fireLevel = readAssetNumber(item, "fire_level"); const fireLevel = readAssetNumber(item, "fire_level");
const resources = getResources(item);
const resourceTooltip = (
<ResourceTooltipContent consumable={consumable} resources={resources} />
);
const details = [ const details = [
["哈夫币", formatCoin(item)], ["哈夫币", formatCoin(item)],
["保险格", readAssetString(item, "season_insurance") || "-"], ["保险格", readAssetString(item, "season_insurance") || "-"],
@@ -675,13 +683,33 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
{chip.label} {chip.label}
</Tag> </Tag>
))} ))}
{resources.length ? <Tag color="orange">{resources.length}</Tag> : null}
{remark ? <span className="remark">{remark}</span> : null} {remark ? <span className="remark">{remark}</span> : null}
</div> </div>
<div className="price-line"> <div className="price-line">
<span> <span>
<strong>{formatMoney(price)}</strong> () <strong>{formatMoney(price)}</strong>
</span> </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> <span>
<strong className="rental-days">{rentalDuration}</strong> <strong className="rental-days">{rentalDuration}</strong>
</span> </span>
@@ -700,6 +728,49 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
); );
} }
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 { function buildQuery(nextPage: number, filters: Filters, sort: string): PublicListingQuery {
return { return {
page: nextPage, page: nextPage,
+2
View File
@@ -10,6 +10,8 @@ export interface ListingResource {
price: string; price: string;
quantity: number; quantity: number;
mode: string; mode: string;
/** 收费项小计(元),赠送为 0 */
amount: number;
} }
export interface Listing { export interface Listing {
+66 -20
View File
@@ -73,18 +73,64 @@ export function formatRatio(item: Listing) {
return `1:${formatRatioNumber(coinWan / price)}`; 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<string, unknown> => 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) { export function formatListingInfoText(item: Listing) {
const code = formatListingCode(item); const code = formatListingCode(item);
const title = item.title?.trim() || `纯币${formatCoin(item)}资源号`; const title = item.title?.trim() || `纯币${formatCoin(item)}资源号`;
const price = getListingPrice(item); const price = getListingPrice(item);
const deposit = getListingDeposit(item); const deposit = getListingDeposit(item);
const consumable = getConsumablePrice(item);
const total = Math.round((price + deposit) * 100) / 100; const total = Math.round((price + deposit) * 100) / 100;
const regions = getRegions(item); const regions = getRegions(item);
const skinNames = getSkinNames(item); const skinNames = getSkinNames(item);
const remark = typeof item.asset_summary?.remark === "string" ? item.asset_summary.remark : ""; const remark = typeof item.asset_summary?.remark === "string" ? item.asset_summary.remark : "";
const online = getOnlineTimeText(item); const online = getOnlineTimeText(item);
const fireLevel = readAssetNumber(item, "fire_level"); const fireLevel = readAssetNumber(item, "fire_level");
const resourceLines = formatResourceDetailLines(item);
const lines = [ const lines = [
`${title} 编号 ${code}`, `${title} 编号 ${code}`,
@@ -104,7 +150,9 @@ export function formatListingInfoText(item: Listing) {
fireLevel ? `烽火:${fireLevel}` : "", fireLevel ? `烽火:${fireLevel}` : "",
item.rank_level ? `段位:${item.rank_level}` : "", item.rank_level ? `段位:${item.rank_level}` : "",
remark ? `备注:${remark}` : "", remark ? `备注:${remark}` : "",
`租金:¥${formatMoney(price)}`, `租金(包含额外物品):¥${formatMoney(price)}`,
`额外消耗品:¥${formatMoney(consumable)}`,
resourceLines.length ? `额外物品明细:\n${resourceLines.map((line) => ` - ${line}`).join("\n")}` : "",
`租期:${formatEstimatedRentalDuration(item)}`, `租期:${formatEstimatedRentalDuration(item)}`,
`押金:¥${formatMoney(deposit)}`, `押金:¥${formatMoney(deposit)}`,
`合计:¥${formatMoney(total)}`, `合计:¥${formatMoney(total)}`,
@@ -128,25 +176,6 @@ export function readAssetNumber(item: Listing, key: string) {
return 0; 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<string, unknown> => 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) { export function getSkinNames(item: Listing) {
const skinGroups = item.asset_summary?.skin_groups; const skinGroups = item.asset_summary?.skin_groups;
if (typeof skinGroups !== "object" || skinGroups === null) return []; if (typeof skinGroups !== "object" || skinGroups === null) return [];
@@ -188,3 +217,20 @@ function formatRatioNumber(value: number) {
const rounded = Math.round(value * 10) / 10; const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); 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;
}
+160
View File
@@ -702,6 +702,166 @@ input {
color: #ff5b66; 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 { .price-line .ant-btn {
margin-left: auto; margin-left: auto;
padding-right: 0; padding-right: 0;