展示额外消耗品总价与明细悬浮卡片
租金改为「包含额外物品」;按 resources 收费项汇总额外消耗品金额,悬浮展示物品明细。
This commit is contained in:
+72
-1
@@ -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 = (
|
||||
<ResourceTooltipContent consumable={consumable} resources={resources} />
|
||||
);
|
||||
const details = [
|
||||
["哈夫币", formatCoin(item)],
|
||||
["保险格", readAssetString(item, "season_insurance") || "-"],
|
||||
@@ -675,13 +683,33 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
|
||||
{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>
|
||||
租金(包含额外物品) <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>
|
||||
@@ -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 {
|
||||
return {
|
||||
page: nextPage,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ListingResource {
|
||||
price: string;
|
||||
quantity: number;
|
||||
mode: string;
|
||||
/** 收费项小计(元),赠送为 0 */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface Listing {
|
||||
|
||||
+66
-20
@@ -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<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) {
|
||||
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<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) {
|
||||
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;
|
||||
}
|
||||
|
||||
+160
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user