feat(settlement): 优化回水分摊明细展示及分组逻辑
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 新增多语言支持,补充回水分摊相关字段翻译
- 优化回水分摊数据汇总逻辑,合并相同参与方的数据
- 增加分摊占比显示,提升明细信息直观性
- 实现按注单分组的回水明细展开与收起功能
- 使用表格展示汇总数据,提升界面可读性和美观度
- 调整样式和交互细节,增强用户体验
This commit is contained in:
2026-07-02 14:57:55 +08:00
parent 409b3ddd87
commit ef8e763551
4 changed files with 157 additions and 77 deletions

View File

@@ -270,6 +270,13 @@
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
"rebateAllocParticipant": "Participant",
"rebateAllocRate": "Share",
"rebateAllocAmount": "Allocated",
"rebateAllocTotal": "Total",
"rebateAllocTicket": "Ticket",
"showRawRebateAllocations": "Show per-ticket detail ({{count}})",
"hideRawRebateAllocations": "Hide per-ticket detail",
"payment": "Payment",
"flowHint": {
"playerPayAgent": "Player should settle with the direct agent",

View File

@@ -272,6 +272,13 @@
"recordReceiptFrom": "रसिद दर्ता ({{payer}} → {{payee}})",
"recordPayoutTo": "भुक्तानी दर्ता ({{payer}} → {{payee}})",
"rebateAllocationsHint": "एजेन्ट तहहरूमा रिबेट कसरी बाँडिन्छ।",
"rebateAllocParticipant": "सहभागी",
"rebateAllocRate": "शेयर",
"rebateAllocAmount": "बाँडफाँड रकम",
"rebateAllocTotal": "कुल",
"rebateAllocTicket": "टिकट",
"showRawRebateAllocations": "टिकट अनुसार विवरण देखाउनुहोस् ({{count}})",
"hideRawRebateAllocations": "टिकट विवरण लुकाउनुहोस्",
"payment": "भुक्तानी",
"flowHint": {
"playerPayAgent": "खेलाडीले प्रत्यक्ष एजेन्टसँग सेटल गर्नुपर्छ",

View File

@@ -255,6 +255,13 @@
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}}",
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}}",
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
"rebateAllocParticipant": "参与方",
"rebateAllocRate": "占成",
"rebateAllocAmount": "分摊金额",
"rebateAllocTotal": "合计",
"rebateAllocTicket": "注单",
"showRawRebateAllocations": "展开按注单明细 ({{count}})",
"hideRawRebateAllocations": "收起按注单明细",
"payment": "收付",
"flowHint": {
"playerPayAgent": "玩家应向直属代理结算",

View File

@@ -199,30 +199,40 @@ export function AgentBillDetail({
button: null,
onClick: null,
};
const rebateAllocationSummary = Object.values(
rebateAllocations.reduce<Record<string, { key: string; label: string; amount: number; rows: number }>>(
(acc, row) => {
const label = row.participant_label ?? `${row.participant_type}#${row.participant_id}`;
const key = `${row.participant_type}:${row.participant_id}:${row.allocation_rule}`;
const current = acc[key];
if (current) {
current.amount += row.allocated_amount;
current.rows += 1;
return acc;
}
const rebateAllocationSummary = (() => {
const groups: Record<string, { key: string; label: string; shareRate: number; amount: number; rows: number; rule: string }> = {};
rebateAllocations.forEach((row) => {
const label = row.participant_label ?? (row.participant_type === 'platform' ? 'Platform' : `${row.participant_type}#${row.participant_id}`);
const key = `${row.participant_type}:${row.participant_id}`;
const current = groups[key];
if (current) {
current.amount += row.allocated_amount;
current.rows += 1;
return;
}
groups[key] = {
key,
label,
shareRate: row.actual_share_rate,
amount: row.allocated_amount,
rows: 1,
rule: row.allocation_rule,
};
});
return Object.values(groups).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, 'zh-CN'));
})();
acc[key] = {
key,
label: `${label} · ${row.allocation_rule}`,
amount: row.allocated_amount,
rows: 1,
};
const rebateTotalAmount = rebateAllocationSummary.reduce((s, r) => s + r.amount, 0);
return acc;
},
{},
),
).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, "zh-CN"));
const rebateDetailByTicket = (() => {
const groups: Record<number, { rebateRecordId: number; rows: typeof rebateAllocations }> = {};
rebateAllocations.forEach((row) => {
const rid = row.rebate_record_id;
if (!groups[rid]) groups[rid] = { rebateRecordId: rid, rows: [] };
groups[rid].rows.push(row);
});
return Object.values(groups);
})();
const showActionError = (err: unknown, fallback: string): void => {
toast.error(err instanceof LotteryApiBizError ? err.message : fallback);
@@ -428,62 +438,111 @@ export function AgentBillDetail({
) : null}
{rebateAllocations.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
defaultValue: "各层级代理对回水的承担明细。",
})}
</p>
<div className="mt-3 space-y-3">
<ul className="space-y-1.5 text-muted-foreground">
{rebateAllocationSummary.map((row) => (
<li key={row.key} className="flex justify-between gap-2">
<span className="min-w-0">
{row.label}
<span className="ml-2 text-xs text-muted-foreground/60">
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
</span>
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</span>
</li>
))}
</ul>
<button
type="button"
className="text-xs font-medium text-primary underline-offset-4 hover:underline"
onClick={() => setRebateDetailsOpen((open) => !open)}
>
{rebateDetailsOpen
? t("settlementCenter:billDisplay.hideRawRebateAllocations", {
defaultValue: "收起原始明细",
})
: t("settlementCenter:billDisplay.showRawRebateAllocations", {
defaultValue: "展开原始明细",
})}
</button>
{rebateDetailsOpen ? (
<ul className="max-h-[280px] space-y-1.5 overflow-y-auto rounded-lg border border-dashed border-border/70 p-3 pr-2 text-muted-foreground">
{rebateAllocations.map((row) => (
<li key={row.id} className="flex justify-between gap-2">
<span>
{row.participant_label ?? `${row.participant_type}#${row.participant_id}`} ·{" "}
{row.allocation_rule}
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</span>
</li>
))}
</ul>
) : null}
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="flex items-baseline justify-between">
<p className="font-semibold tracking-tight">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
</p>
<span className="text-xs text-muted-foreground/70">
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
defaultValue: "各层级承担明细",
})}
</span>
</div>
{/* 汇总表格 */}
<div className="rounded-lg border border-border/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 text-xs text-muted-foreground">
<th className="px-3 py-1.5 text-left font-medium">
{t("settlementCenter:billDisplay.rebateAllocParticipant", { defaultValue: "参与方" })}
</th>
<th className="px-3 py-1.5 text-right font-medium">
{t("settlementCenter:billDisplay.rebateAllocRate", { defaultValue: "占成" })}
</th>
<th className="px-3 py-1.5 text-right font-medium">
{t("settlementCenter:billDisplay.rebateAllocAmount", { defaultValue: "分摊金额" })}
</th>
</tr>
</thead>
<tbody>
{rebateAllocationSummary.map((row) => {
const pct = rebateTotalAmount > 0
? Math.round((row.amount / rebateTotalAmount) * 100)
: 0;
return (
<tr key={row.key} className="border-t border-border/40">
<td className="px-3 py-2 font-medium text-foreground">{row.label}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{row.shareRate > 0 ? `${row.shareRate}%` : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-foreground">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
<span className="ml-1 text-xs text-muted-foreground/60">({pct}%)</span>
</td>
</tr>
);
})}
{rebateAllocationSummary.length > 1 ? (
<tr className="border-t border-border/60 bg-muted/20">
<td className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{t("settlementCenter:billDisplay.rebateAllocTotal", { defaultValue: "合计" })}
</td>
<td className="px-3 py-1.5" />
<td className="px-3 py-1.5 text-right text-xs font-semibold tabular-nums text-foreground">
{formatDashboardMoneyMinor(rebateTotalAmount, currencyCode)}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{/* 按注单分组展开 */}
{rebateDetailByTicket.length > 1 ? (
<>
<button
type="button"
className="text-xs font-medium text-primary underline-offset-4 hover:underline"
onClick={() => setRebateDetailsOpen((open) => !open)}
>
{rebateDetailsOpen
? t("settlementCenter:billDisplay.hideRawRebateAllocations", {
defaultValue: "收起按注单明细",
})
: t("settlementCenter:billDisplay.showRawRebateAllocations", {
defaultValue: "展开按注单明细 ({{count}})",
count: rebateDetailByTicket.length,
})}
</button>
{rebateDetailsOpen ? (
<div className="max-h-[320px] space-y-2 overflow-y-auto rounded-lg border border-dashed border-border/70 p-3">
{rebateDetailByTicket.map((group, idx) => (
<div key={group.rebateRecordId} className="space-y-1 rounded-md bg-muted/10 p-2">
<p className="text-[11px] font-medium text-muted-foreground/70">
{t("settlementCenter:billDisplay.rebateAllocTicket", { defaultValue: "注单" })} #{idx + 1}
</p>
{group.rows.map((row) => (
<div key={row.id} className="flex justify-between gap-2 text-xs text-muted-foreground">
<span>
{row.participant_label ?? (row.participant_type === 'platform' ? 'Platform' : `#${row.participant_id}`)}
{row.actual_share_rate > 0 ? (
<span className="ml-1 text-muted-foreground/50">({row.actual_share_rate}%)</span>
) : null}
</span>
<span className="shrink-0 tabular-nums text-foreground">
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</span>
</div>
))}
</div>
))}
</div>
) : null}
</>
) : null}
</div>
) : null}