feat(admin-web): add ledger detail display for system accounts

- Add getAllLedger API method in systemAccountReportService
- Add LedgerEntryDTO, FixedAccountLedger, RegionAccountLedger types
- Add ALL_LEDGER endpoint
- Update SystemAccountsTab with ledger detail tab
- Add expandable card UI for viewing account ledger entries
- Add styles for ledger cards and tables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-04 23:45:17 -08:00
parent c3c15b7880
commit 44a1023cdd
5 changed files with 443 additions and 1 deletions

View File

@ -285,3 +285,126 @@
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
/* [2026-01-05] 新增:分类账明细样式 */
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.refreshButton {
padding: 6px 12px;
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
&:hover {
background-color: #e5e7eb;
}
}
.ledgerGroup {
margin-top: 16px;
}
.ledgerCard {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.ledgerCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
cursor: pointer;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
&:hover {
background-color: #f9fafb;
}
}
.ledgerCardTitle {
display: flex;
align-items: center;
gap: 12px;
.accountName {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.accountSequence {
font-size: 12px;
color: #9ca3af;
font-family: monospace;
}
}
.ledgerCardInfo {
display: flex;
align-items: center;
gap: 12px;
}
.ledgerCount {
font-size: 13px;
color: #6b7280;
}
.expandIcon {
font-size: 12px;
color: #9ca3af;
}
.ledgerCardBody {
padding: 16px;
background-color: #fff;
}
.emptyLedger {
text-align: center;
padding: 24px;
color: #9ca3af;
font-size: 14px;
}
.entryTypeBadge {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
border-radius: 4px;
background-color: #e5e7eb;
color: #4b5563;
}
.amountPositive {
color: #059669;
font-weight: 500;
}
.amountNegative {
color: #dc2626;
font-weight: 500;
}
.memo {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: #6b7280;
}

View File

@ -1,6 +1,7 @@
/**
* Tab组件
* [2026-01-04]
* [2026-01-05]
* system-account-report
*/
'use client';
@ -10,7 +11,12 @@ import { systemAccountReportService } from '@/services/systemAccountReportServic
import type {
SystemAccountReportResponse,
RegionAccountsSummary,
AllSystemAccountsLedgerResponse,
FixedAccountLedger,
RegionAccountLedger,
LedgerEntryDTO,
} from '@/types';
import { ENTRY_TYPE_LABELS, ACCOUNT_TYPE_LABELS } from '@/types';
import styles from './SystemAccountsTab.module.scss';
/**
@ -46,7 +52,9 @@ export default function SystemAccountsTab() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<SystemAccountReportResponse | null>(null);
const [activeTab, setActiveTab] = useState<'fixed' | 'province' | 'city' | 'settlement' | 'expired'>('fixed');
const [ledgerData, setLedgerData] = useState<AllSystemAccountsLedgerResponse | null>(null);
const [ledgerLoading, setLedgerLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'fixed' | 'province' | 'city' | 'settlement' | 'expired' | 'ledger'>('fixed');
// 加载报表数据
const loadReportData = useCallback(async () => {
@ -65,10 +73,32 @@ export default function SystemAccountsTab() {
}
}, []);
// 加载分类账明细
const loadLedgerData = useCallback(async () => {
setLedgerLoading(true);
try {
const response = await systemAccountReportService.getAllLedger({ pageSize: 50 });
if (response.data) {
setLedgerData(response.data);
}
} catch (err) {
console.error('Failed to load ledger data:', err);
} finally {
setLedgerLoading(false);
}
}, []);
useEffect(() => {
loadReportData();
}, [loadReportData]);
// 切换到分类账明细时加载数据
useEffect(() => {
if (activeTab === 'ledger' && !ledgerData && !ledgerLoading) {
loadLedgerData();
}
}, [activeTab, ledgerData, ledgerLoading, loadLedgerData]);
if (loading) {
return (
<div className={styles.loading}>
@ -127,6 +157,12 @@ export default function SystemAccountsTab() {
>
</button>
<button
className={`${styles.tab} ${activeTab === 'ledger' ? styles.active : ''}`}
onClick={() => setActiveTab('ledger')}
>
</button>
</div>
{/* 内容区域 */}
@ -136,6 +172,7 @@ export default function SystemAccountsTab() {
{activeTab === 'city' && <RegionAccountsSection data={reportData.citySummary} type="city" />}
{activeTab === 'settlement' && <OfflineSettlementSection data={reportData.offlineSettlement} />}
{activeTab === 'expired' && <ExpiredRewardsSection data={reportData.expiredRewards} />}
{activeTab === 'ledger' && <LedgerSection data={ledgerData} loading={ledgerLoading} onRefresh={loadLedgerData} />}
</div>
{/* 报表生成时间 */}
@ -392,3 +429,209 @@ function ExpiredRewardsSection({ data }: { data: SystemAccountReportResponse['ex
</div>
);
}
// [2026-01-05] 新增:分类账明细组件
/**
*
*/
function LedgerSection({
data,
loading,
onRefresh,
}: {
data: AllSystemAccountsLedgerResponse | null;
loading: boolean;
onRefresh: () => void;
}) {
const [expandedAccounts, setExpandedAccounts] = useState<Set<string>>(new Set());
const toggleExpand = (accountKey: string) => {
setExpandedAccounts((prev) => {
const newSet = new Set(prev);
if (newSet.has(accountKey)) {
newSet.delete(accountKey);
} else {
newSet.add(accountKey);
}
return newSet;
});
};
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>...</span>
</div>
);
}
if (!data) {
return (
<div className={styles.section}>
<div className={styles.emptyTable}>
<span></span>
<button onClick={onRefresh} className={styles.retryButton}>
</button>
</div>
</div>
);
}
const getEntryTypeLabel = (type: string): string => {
return ENTRY_TYPE_LABELS[type] || type;
};
const getAccountTypeLabel = (type: string): string => {
return ACCOUNT_TYPE_LABELS[type] || type;
};
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}></h3>
<button onClick={onRefresh} className={styles.refreshButton}>
</button>
</div>
{/* 固定系统账户分类账 */}
{data.fixedAccountsLedger && data.fixedAccountsLedger.length > 0 && (
<div className={styles.ledgerGroup}>
<h4 className={styles.subTitle}></h4>
{data.fixedAccountsLedger.map((account) => (
<LedgerAccountCard
key={account.accountSequence}
title={getAccountTypeLabel(account.accountType)}
subtitle={account.accountSequence}
ledger={account.ledger}
total={account.total}
expanded={expandedAccounts.has(account.accountSequence)}
onToggle={() => toggleExpand(account.accountSequence)}
getEntryTypeLabel={getEntryTypeLabel}
/>
))}
</div>
)}
{/* 省区域账户分类账 */}
{data.provinceAccountsLedger && data.provinceAccountsLedger.length > 0 && (
<div className={styles.ledgerGroup}>
<h4 className={styles.subTitle}> ({data.provinceAccountsLedger.length})</h4>
{data.provinceAccountsLedger.map((account) => (
<LedgerAccountCard
key={account.accountSequence}
title={account.regionName || account.regionCode}
subtitle={account.accountSequence}
ledger={account.ledger}
total={account.total}
expanded={expandedAccounts.has(account.accountSequence)}
onToggle={() => toggleExpand(account.accountSequence)}
getEntryTypeLabel={getEntryTypeLabel}
/>
))}
</div>
)}
{/* 市区域账户分类账 */}
{data.cityAccountsLedger && data.cityAccountsLedger.length > 0 && (
<div className={styles.ledgerGroup}>
<h4 className={styles.subTitle}> ({data.cityAccountsLedger.length})</h4>
{data.cityAccountsLedger.map((account) => (
<LedgerAccountCard
key={account.accountSequence}
title={account.regionName || account.regionCode}
subtitle={account.accountSequence}
ledger={account.ledger}
total={account.total}
expanded={expandedAccounts.has(account.accountSequence)}
onToggle={() => toggleExpand(account.accountSequence)}
getEntryTypeLabel={getEntryTypeLabel}
/>
))}
</div>
)}
{(!data.fixedAccountsLedger || data.fixedAccountsLedger.length === 0) &&
(!data.provinceAccountsLedger || data.provinceAccountsLedger.length === 0) &&
(!data.cityAccountsLedger || data.cityAccountsLedger.length === 0) && (
<div className={styles.emptyTable}></div>
)}
</div>
);
}
/**
*
*/
function LedgerAccountCard({
title,
subtitle,
ledger,
total,
expanded,
onToggle,
getEntryTypeLabel,
}: {
title: string;
subtitle: string;
ledger: LedgerEntryDTO[];
total: number;
expanded: boolean;
onToggle: () => void;
getEntryTypeLabel: (type: string) => string;
}) {
return (
<div className={styles.ledgerCard}>
<div className={styles.ledgerCardHeader} onClick={onToggle}>
<div className={styles.ledgerCardTitle}>
<span className={styles.accountName}>{title}</span>
<span className={styles.accountSequence}>{subtitle}</span>
</div>
<div className={styles.ledgerCardInfo}>
<span className={styles.ledgerCount}>{total} </span>
<span className={styles.expandIcon}>{expanded ? '▼' : '▶'}</span>
</div>
</div>
{expanded && (
<div className={styles.ledgerCardBody}>
{ledger.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{ledger.map((entry) => (
<tr key={entry.id}>
<td>{new Date(entry.createdAt).toLocaleString('zh-CN')}</td>
<td>
<span className={styles.entryTypeBadge}>
{getEntryTypeLabel(entry.entryType)}
</span>
</td>
<td className={entry.amount >= 0 ? styles.amountPositive : styles.amountNegative}>
{entry.amount >= 0 ? '+' : ''}{formatAmount(entry.amount)} {entry.assetType}
</td>
<td>{entry.balanceAfter !== null ? formatAmount(entry.balanceAfter) : '-'}</td>
<td className={styles.memo}>{entry.memo || entry.allocationType || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.emptyLedger}></div>
)}
</div>
)}
</div>
);
}

View File

@ -214,5 +214,7 @@ export const API_ENDPOINTS = {
CITY_SUMMARY: '/v1/system-account-reports/city-summary',
OFFLINE_SETTLEMENT: '/v1/system-account-reports/offline-settlement',
EXPIRED_REWARDS: '/v1/system-account-reports/expired-rewards',
// [2026-01-05] 新增:所有系统账户分类账明细
ALL_LEDGER: '/v1/system-account-reports/all-ledger',
},
} as const;

View File

@ -13,6 +13,7 @@ import type {
RegionAccountsSummary,
OfflineSettlementSummary,
ExpiredRewardsSummary,
AllSystemAccountsLedgerResponse,
} from '@/types';
/**
@ -68,6 +69,14 @@ export const systemAccountReportService = {
async getExpiredRewards(params?: DateRangeParams): Promise<ApiResponse<ExpiredRewardsSummary>> {
return apiClient.get(API_ENDPOINTS.SYSTEM_ACCOUNT_REPORTS.EXPIRED_REWARDS, { params });
},
// [2026-01-05] 新增:获取所有系统账户分类账明细
/**
*
*/
async getAllLedger(params?: DateRangeParams & { pageSize?: number }): Promise<ApiResponse<AllSystemAccountsLedgerResponse>> {
return apiClient.get(API_ENDPOINTS.SYSTEM_ACCOUNT_REPORTS.ALL_LEDGER, { params });
},
};
export default systemAccountReportService;

View File

@ -120,3 +120,68 @@ export const RIGHT_TYPE_LABELS: Record<string, string> = {
CITY_REGION: '市区域权益',
COMMUNITY: '社区权益',
};
// [2026-01-05] 新增:分类账明细类型
/**
*
*/
export interface LedgerEntryDTO {
id: string;
entryType: string;
amount: number;
assetType: string;
balanceAfter: number | null;
refOrderId: string | null;
refTxHash: string | null;
memo: string | null;
allocationType: string | null;
createdAt: string;
}
/**
*
*/
export interface FixedAccountLedger {
accountSequence: string;
accountType: string;
ledger: LedgerEntryDTO[];
total: number;
}
/**
*
*/
export interface RegionAccountLedger {
accountSequence: string;
regionCode: string;
regionName: string;
ledger: LedgerEntryDTO[];
total: number;
}
/**
*
*/
export interface AllSystemAccountsLedgerResponse {
fixedAccountsLedger: FixedAccountLedger[];
provinceAccountsLedger: RegionAccountLedger[];
cityAccountsLedger: RegionAccountLedger[];
}
/**
*
*/
export const ENTRY_TYPE_LABELS: Record<string, string> = {
DEPOSIT: '充值',
WITHDRAW: '提现',
TRANSFER_IN: '转入',
TRANSFER_OUT: '转出',
REWARD_INCOME: '奖励收入',
REWARD_SETTLE: '奖励结算',
REWARD_EXPIRED: '奖励过期',
PLANTING_PAYMENT: '认种支付',
OFFLINE_SETTLEMENT: '面对面结算',
FEE_DEDUCTION: '手续费扣除',
ALLOCATION_IN: '分配收入',
ALLOCATION_OUT: '分配支出',
};