feat(finance): 客服端「我的备用金」改为「我的钱包」,四平台并列

客服原来只能看到备用金现金,平台积分藏在 Tab 里且是空的。
现在四个钱包并列展示:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金。

后端:
- 客服查询积分账户时自动补建三个空账户,保证四个钱包始终并列展示,
  不会出现「点了没反应」的空白卡片。

前端:
- 标题「我的备用金」→「我的钱包」,菜单同步改名。
- 顶部四张卡:三个积分平台各一张(显示积分余额 + 折合金额 + 换算率标签),
  备用金现金一张。余额为 0 时置灰。
- 四个钱包卡片与备用金账户解耦:没开通备用金账户的客服也能看到积分钱包,
  只是现金卡显示「尚未开通备用金账户」。
- Tab 调整为「积分流水 / 备用金流水 / 我的支出」,积分流水独立展示
  (平台、类型、变动、余额、折合金额、备注、时间)。
- 备用金账户详情卡(账户编号、责任主管、状态、登记支出、缴回备用金)
  仍然只在有账户时显示。

校验:后端 765 项 / 0 失败,前端 143 项全通过(含 lint、tsc -b、build)。

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-10-24 13:36:38 +08:00
co-authored by Claude Opus 4.5
parent d07d88e25c
commit 0a743f00b1
3 changed files with 227 additions and 115 deletions
@@ -90,6 +90,11 @@ export async function listPlatformCreditAccounts(session: AdminSession) {
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
}
// 客服第一次进来时补建三个空账户,保证四个钱包始终并列展示。
if (!isPrivileged(session)) {
await ensurePlatformCreditAccounts(session.userId)
}
const where = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : ''
const result = await query<CreditAccountRow>(
`
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AdminLayout() {
{
key: '/admin/fund',
icon: <WalletOutlined />,
label: role === 'support' ? '我的备用金' : '备用金',
label: role === 'support' ? '我的钱包' : '备用金',
},
...(role === 'support'
? []
@@ -34,16 +34,18 @@ import {
createAdminFundExpense,
createAdminFundReturn,
fetchAdminFundMyAccount,
fetchAdminPlatformCreditAccounts,
fetchAdminPlatformCreditEntries,
} from '@/services/admin'
import type {
AdminFundEntry,
AdminFundExpense,
AdminFundExpenseCategory,
AdminFundPayeeType,
PlatformCreditEntry,
} from '@/types/admin'
import type { UploadedFile } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
import { PlatformCreditPanel } from './PlatformCreditPanel'
import {
FUND_ACCOUNT_STATUS_LABELS,
FUND_CATEGORY_LABELS,
@@ -94,11 +96,36 @@ export default function FundSupportView() {
const entries = data?.entries || []
const expenses = data?.expenses || []
const creditAccountsQuery = useQuery({
queryKey: ['admin-platform-credit-accounts'],
queryFn: fetchAdminPlatformCreditAccounts,
})
const creditEntriesQuery = useQuery({
queryKey: ['admin-platform-credit-entries', 'mine'],
queryFn: () => fetchAdminPlatformCreditEntries({ pageSize: 50 }),
})
const creditAccounts = creditAccountsQuery.data?.data.items || []
const creditEntries = creditEntriesQuery.data?.data.items || []
const creditPlatforms = creditAccountsQuery.data?.data.platforms || []
/** 客服自己的三个积分钱包,按平台顺序排好。 */
const myCreditWallets = creditPlatforms.map((platform) => {
const found = creditAccounts.find((item) => item.platformKey === platform.key)
return {
platform,
balanceQuantity: found?.balanceQuantity ?? 0,
balanceAmountFen: found?.balanceAmountFen ?? 0,
unitName: found?.unitName ?? '积分',
}
})
function refresh() {
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
}
function openExpense() {
@@ -216,6 +243,71 @@ export default function FundSupportView() {
},
]
const creditEntryColumns: TableColumnsType<PlatformCreditEntry> = [
{
title: '平台',
dataIndex: 'platformKey',
width: 100,
render: (value: string, row) => (
<Tag color={value === 'rock' ? 'cyan' : value === 'lewan' ? 'purple' : 'blue'}>
{row.platformLabel}
</Tag>
),
},
{
title: '类型',
dataIndex: 'entryType',
width: 80,
render: (value: string) => (
<Tag color={value === 'grant' ? 'green' : value === 'consume' ? 'red' : 'gold'}>
{value === 'grant'
? '充值'
: value === 'consume'
? '消耗'
: value === 'return'
? '退回'
: '调整'}
</Tag>
),
},
{
title: '变动',
dataIndex: 'quantityDelta',
width: 100,
align: 'right',
render: (value: number) =>
value > 0 ? (
<Typography.Text style={{ color: '#389e0d' }}>+{value}</Typography.Text>
) : (
<Typography.Text style={{ color: '#cf1322' }}>{value}</Typography.Text>
),
},
{
title: '余额',
dataIndex: 'balanceAfter',
width: 100,
align: 'right',
},
{
title: '折合金额',
dataIndex: 'amountFen',
width: 110,
align: 'right',
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`,
},
{
title: '备注',
dataIndex: 'note',
render: (value: string) => value || '-',
},
{
title: '时间',
dataIndex: 'createdAt',
width: 150,
render: (value: string) => formatAdminDateTime(value),
},
]
const expenseColumns: TableColumnsType<AdminFundExpense> = [
{
title: '付款时间',
@@ -281,10 +373,14 @@ export default function FundSupportView() {
return (
<section className="page-stack">
<PageHeader
title="我的备用金"
description="查看当前余额、登记每一笔支出;余额由流水自动计算,不能手工修改。"
title="我的钱包"
description="四个平台钱包:南航 / 乐玩 / ROCK 积分与备用金现金。余额由流水自动计算,不能手工修改。"
extra={
<Button icon={<ReloadOutlined />} loading={myQuery.isFetching} onClick={refresh}>
<Button
icon={<ReloadOutlined />}
loading={myQuery.isFetching || creditAccountsQuery.isFetching}
onClick={refresh}
>
刷新
</Button>
}
@@ -303,124 +399,135 @@ export default function FundSupportView() {
type="warning"
showIcon
message="还没有备用金账户"
description="请联系主管或管理员为你开通客服备用金账户。"
description="请联系主管或管理员为你开通客服备用金账户。平台积分钱包不受影响,可直接使用。"
/>
) : null}
{account ? (
<>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="当前余额"
value={account.balanceFen / 100}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="本月发放"
value={account.monthInFen / 100}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="本月支出/缴回"
value={account.monthOutFen / 100}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="最近对账"
value={
account.lastReconciledAt
? formatAdminDateTime(account.lastReconciledAt)
: '未对账'
}
valueStyle={{ fontSize: 18 }}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
{myCreditWallets.map((wallet) => (
<Col xs={24} sm={12} lg={6} key={wallet.platform.key}>
<Card>
<Statistic
title={
<Space size={6}>
<span>{wallet.platform.label}</span>
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
1积分=
{wallet.platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
</Tag>
</Space>
}
value={wallet.balanceQuantity}
suffix="积分"
valueStyle={{
color: wallet.balanceQuantity > 0 ? undefined : '#999',
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
</Typography.Text>
</Card>
</Col>
))}
<Col xs={24} sm={12} lg={6}>
<Card>
<Descriptions column={2} size="small">
<Descriptions.Item label="账户编号">{account.accountNo}</Descriptions.Item>
<Descriptions.Item label="责任主管">
{account.responsibleSupervisorName || '-'}
</Descriptions.Item>
<Descriptions.Item label="账户状态">
{FUND_ACCOUNT_STATUS_LABELS[account.status] || account.status}
</Descriptions.Item>
<Descriptions.Item label="最近流水">
{account.lastEntryAt ? formatAdminDateTime(account.lastEntryAt) : '-'}
</Descriptions.Item>
</Descriptions>
<Divider />
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openExpense}>
登记支出
</Button>
<Button icon={<RollbackOutlined />} onClick={openReturn}>
缴回备用金
</Button>
</Space>
</Card>
<Card>
<Tabs
items={[
{
key: 'entries',
label: '资金流水',
children: (
<Table<AdminFundEntry>
rowKey="id"
size="small"
columns={entryColumns}
dataSource={entries}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'expenses',
label: '我的支出',
children: (
<Table<AdminFundExpense>
rowKey="id"
size="small"
columns={expenseColumns}
dataSource={expenses}
pagination={false}
scroll={{ x: 1100 }}
/>
),
},
{
key: 'platform-credits',
label: '平台积分',
children: <PlatformCreditPanel />,
},
]}
<Statistic
title="备用金现金"
value={account ? account.balanceFen / 100 : 0}
precision={2}
prefix="¥"
valueStyle={{
color: account && account.balanceFen > 0 ? undefined : '#999',
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{account
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
: '尚未开通备用金账户'}
</Typography.Text>
</Card>
</>
</Col>
</Row>
{account ? (
<Card>
<Descriptions column={2} size="small">
<Descriptions.Item label="账户编号">{account.accountNo}</Descriptions.Item>
<Descriptions.Item label="责任主管">
{account.responsibleSupervisorName || '-'}
</Descriptions.Item>
<Descriptions.Item label="账户状态">
{FUND_ACCOUNT_STATUS_LABELS[account.status] || account.status}
</Descriptions.Item>
<Descriptions.Item label="最近流水">
{account.lastEntryAt ? formatAdminDateTime(account.lastEntryAt) : '-'}
</Descriptions.Item>
</Descriptions>
<Divider />
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openExpense}>
登记支出
</Button>
<Button icon={<RollbackOutlined />} onClick={openReturn}>
缴回备用金
</Button>
</Space>
</Card>
) : null}
<Card>
<Tabs
items={[
{
key: 'credit-entries',
label: '积分流水',
children: (
<Table<PlatformCreditEntry>
rowKey="id"
size="small"
columns={creditEntryColumns}
dataSource={creditEntries}
loading={creditEntriesQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
locale={{ emptyText: '还没有积分流水' }}
/>
),
},
{
key: 'entries',
label: '备用金流水',
children: (
<Table<AdminFundEntry>
rowKey="id"
size="small"
columns={entryColumns}
dataSource={entries}
pagination={false}
scroll={{ x: 900 }}
locale={{ emptyText: '还没有备用金流水' }}
/>
),
},
{
key: 'expenses',
label: '我的支出',
children: (
<Table<AdminFundExpense>
rowKey="id"
size="small"
columns={expenseColumns}
dataSource={expenses}
pagination={false}
scroll={{ x: 1100 }}
locale={{ emptyText: '还没有支出记录' }}
/>
),
},
]}
/>
</Card>
<Modal
title="登记备用金支出"
open={expenseOpen}