feat: 新增客服备用金台账与后台用户中文显示名

- 新增备用金账户、流水、支出、对账与权限
- 后台用户支持中文显示名并同步备用金姓名
- 审计、登录记录、任务及工单操作人优先展示中文名
- 补充备用金与人工支出方案文档
This commit is contained in:
yml2213
2026-10-18 13:55:24 +08:00
parent 7bacb7d5eb
commit 7542d547bf
55 changed files with 6557 additions and 105 deletions
+4
View File
@@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
const AdminDouyinMashidaCertificatePage = lazy(
@@ -145,6 +146,9 @@ function AdminApplication() {
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="fund" element={<AdminFundPage />} />
</Route>
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
+17 -1
View File
@@ -17,6 +17,7 @@ import {
TrophyOutlined,
UnorderedListOutlined,
UserOutlined,
WalletOutlined,
} from '@ant-design/icons'
import {
App,
@@ -45,10 +46,12 @@ import {
} from '@/services/admin'
import {
clearAdminSession,
getAdminDisplayName,
getAdminRole,
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
setAdminDisplayName,
setAdminPermissions,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
@@ -89,9 +92,13 @@ export default function AdminLayout() {
const session = adminSessionQuery.data?.data
if (session?.authenticated && session.user) {
setAdminPermissions(session.user.permissions || [])
setAdminDisplayName(session.user.displayName || session.user.username)
}
}, [adminSessionQuery.data])
const displayName =
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
const devMockStatusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
queryFn: () => fetchDevMockStatus(),
@@ -124,11 +131,17 @@ export default function AdminLayout() {
role === 'finance'
? [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
]
: [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
{
key: '/admin/fund',
icon: <WalletOutlined />,
label: role === 'support' ? '我的备用金' : '备用金',
},
...(role === 'support'
? []
: [{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' }]),
@@ -291,7 +304,10 @@ export default function AdminLayout() {
{!collapsed ? (
<div className="admin-sidebar-profile">
<Typography.Text strong>{username}</Typography.Text>
<Typography.Text strong>{displayName}</Typography.Text>
{displayName !== username ? (
<Typography.Text type="secondary">@{username}</Typography.Text>
) : null}
<Typography.Text type="secondary">{roleLabel(role)}</Typography.Text>
{expiresAt ? (
<Typography.Text type="secondary">
@@ -72,7 +72,12 @@ export default function AdminAuditLogsPage() {
minWidth: 140,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
<Typography.Text strong>
{row.actorDisplayName || row.actorUsername || '-'}
</Typography.Text>
{row.actorDisplayName && row.actorDisplayName !== row.actorUsername ? (
<Typography.Text type="secondary">@{row.actorUsername}</Typography.Text>
) : null}
<StatusTag value={row.actorRole} />
</div>
),
@@ -155,7 +160,7 @@ export default function AdminAuditLogsPage() {
className="filter-form"
>
<Form.Item name="actorUsername">
<Input allowClear placeholder="操作账号" />
<Input allowClear placeholder="姓名/账号" />
</Form.Item>
<Form.Item name="action">
<Select
@@ -80,10 +80,16 @@ export default function AdminDashboardPage() {
render: (value) => formatAdminDateTime(value),
},
{
title: '账号',
dataIndex: 'username',
minWidth: 120,
render: (value) => <Typography.Text strong>{value || '-'}</Typography.Text>,
title: '姓名',
minWidth: 140,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.displayName || row.username || '-'}</Typography.Text>
{row.displayName && row.displayName !== row.username ? (
<Typography.Text type="secondary">@{row.username}</Typography.Text>
) : null}
</div>
),
},
{
title: '角色',
@@ -186,8 +192,8 @@ export default function AdminDashboardPage() {
initialValues={{ username: filters.username }}
style={{ marginBottom: 16 }}
>
<Form.Item name="username" label="账号">
<Input allowClear placeholder="用户名" style={{ width: 180 }} />
<Form.Item name="username" label="姓名/账号">
<Input allowClear placeholder="姓名或账号" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="dateRange" label="登录时段">
<AdminRangePicker />
@@ -0,0 +1,13 @@
import { getAdminRole } from '@/utils/admin-auth'
import FundManagementView from './fund/FundManagementView'
import FundSupportView from './fund/FundSupportView'
export default function AdminFundPage() {
const role = getAdminRole()
if (role === 'support') {
return <FundSupportView />
}
return <FundManagementView role={role} />
}
@@ -1698,8 +1698,10 @@ function ManualDispatchPanel({
{formatAdminDateTime(detail.manualDispatch.completedAt)}
</Descriptions.Item>
<Descriptions.Item label="处理人">
{detail.manualDispatch.completedBy?.username || '-'} /{' '}
{detail.manualDispatch.completedBy?.role || '-'}
{detail.manualDispatch.completedBy?.displayName ||
detail.manualDispatch.completedBy?.username ||
'-'}{' '}
/ {detail.manualDispatch.completedBy?.role || '-'}
</Descriptions.Item>
<Descriptions.Item label="履约单号">
{detail.manualDispatch.deliveryReference || '-'}
+101 -15
View File
@@ -27,12 +27,13 @@ import {
fetchAdminRolePermissions,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUserDisplayName,
updateAdminUserRole,
updateAdminUserStatus,
updateAdminRolePermissions,
} from '@/services/admin'
import type { AdminRole, AdminUserListItem } from '@/types/admin'
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
import { getAdminUserId, hasAdminRole, setAdminDisplayName } from '@/utils/admin-auth'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
import {
ADMIN_DEFAULT_PAGE_SIZE,
@@ -48,11 +49,16 @@ type UserFilterForm = {
}
type CreateUserForm = {
displayName?: string
username: string
password: string
role: AdminRole
}
type DisplayNameForm = {
displayName: string
}
const USER_ROLE_OPTIONS = [
{ label: '普通运营', value: 'operator' },
{ label: '客服', value: 'support' },
@@ -119,6 +125,7 @@ export default function AdminUsersPage() {
try {
await createAdminUser({
username: values.username.trim(),
displayName: values.displayName?.trim() || '',
password: values.password.trim(),
role: values.role,
status: 'active',
@@ -139,7 +146,7 @@ export default function AdminUsersPage() {
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
content: `确认将 ${displayNameOf(item)} 调整为${formatRoleLabel(nextRole)}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
@@ -163,7 +170,7 @@ export default function AdminUsersPage() {
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
content: `确认将 ${displayNameOf(item)}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
@@ -182,6 +189,66 @@ export default function AdminUsersPage() {
})
}
function openEditDisplayNameModal(item: AdminUserListItem) {
const form = ModalFormStore.create<DisplayNameForm>()
modal.confirm({
title: '修改姓名',
content: (
<Form
layout="vertical"
preserve={false}
initialValues={{ displayName: item.displayName || item.username }}
ref={form.bind}
className="modal-form"
>
<Form.Item
name="displayName"
label="姓名(展示用,可输入中文)"
rules={[{ max: 32, message: '姓名最多 32 个字符' }]}
extra="留空时回退显示登录账号"
>
<Input placeholder="例如:张三" maxLength={32} showCount />
</Form.Item>
</Form>
),
okText: '保存',
cancelText: '取消',
centered: true,
onOk: async () => {
const values = await form.validate()
setActionLoadingId(item.userId)
try {
const response = await updateAdminUserDisplayName(item.userId, {
displayName: values.displayName?.trim() || '',
})
if (item.userId === currentUserId) {
setAdminDisplayName(response.data.user.displayName)
}
message.success('用户姓名已更新')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-session'] }),
queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
queryClient.invalidateQueries({ queryKey: ['admin-audit-logs'] }),
queryClient.invalidateQueries({ queryKey: ['admin-login-logs'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-reconciliations'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-account-detail'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-options'] }),
queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] }),
])
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新用户姓名失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function openResetPasswordModal(item: AdminUserListItem) {
const form = ModalFormStore.create<{ password: string }>()
@@ -197,7 +264,7 @@ export default function AdminUsersPage() {
>
<Form.Item
name="password"
label={`请输入 ${item.username} 的新密码`}
label={`请输入 ${displayNameOf(item)} 的新密码`}
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
@@ -251,16 +318,22 @@ export default function AdminUsersPage() {
align: 'center',
},
{
title: '账号',
dataIndex: 'username',
minWidth: 150,
render: (value, row) => (
title: '姓名',
dataIndex: 'displayName',
minWidth: 160,
render: (value: string, row) => (
<Space>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text strong>{value || row.username}</Typography.Text>
{row.userId === currentUserId ? <Tag>当前账号</Tag> : null}
</Space>
),
},
{
title: '账号',
dataIndex: 'username',
minWidth: 140,
render: (value: string) => <Typography.Text type="secondary">{value}</Typography.Text>,
},
{
title: '角色',
dataIndex: 'role',
@@ -287,7 +360,7 @@ export default function AdminUsersPage() {
},
{
title: '操作',
width: 310,
width: 390,
render: (_, row) => {
const isCurrent = row.userId === currentUserId
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
@@ -298,6 +371,12 @@ export default function AdminUsersPage() {
return (
<Space wrap>
<Button
loading={actionLoadingId === row.userId}
onClick={() => openEditDisplayNameModal(row)}
>
改名
</Button>
<Dropdown
disabled={isCurrent}
menu={{
@@ -333,7 +412,7 @@ export default function AdminUsersPage() {
<section className="page-stack">
<PageHeader
title="后台用户"
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
/>
<Card className="users-action-card">
@@ -347,7 +426,7 @@ export default function AdminUsersPage() {
className="filter-form users-action-form"
>
<Form.Item name="username">
<Input allowClear placeholder="账号筛选" className="users-input-md" />
<Input allowClear placeholder="姓名/账号筛选" className="users-input-md" />
</Form.Item>
<Form.Item name="role">
<Select
@@ -387,8 +466,11 @@ export default function AdminUsersPage() {
onFinish={submitCreate}
className="filter-form users-action-form"
>
<Form.Item name="displayName" rules={[{ max: 32, message: '姓名最多 32 个字符' }]}>
<Input placeholder="姓名(可中文)" className="users-input-md" maxLength={32} />
</Form.Item>
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
<Input placeholder="新账号" className="users-input-md" />
<Input placeholder="登录账号(英文/数字)" className="users-input-md" />
</Form.Item>
<Form.Item
name="password"
@@ -424,7 +506,7 @@ export default function AdminUsersPage() {
title="用户列表"
extra={
<Typography.Text type="secondary">
共 {data?.pagination.total || 0} 个账号
共 {data?.pagination.total || 0} 个用户
</Typography.Text>
}
>
@@ -434,7 +516,7 @@ export default function AdminUsersPage() {
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无用户" /> }}
scroll={{ x: 1060 }}
scroll={{ x: 1220 }}
pagination={buildAdminTablePagination({
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
@@ -487,6 +569,10 @@ function formatRoleLabel(role: AdminRole) {
return '普通运营'
}
function displayNameOf(user: AdminUserListItem) {
return user.displayName || user.username
}
function cleanParams(params: Record<string, unknown>) {
const next = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,564 @@
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Checkbox,
Col,
DatePicker,
Descriptions,
Divider,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import dayjs from 'dayjs'
import { useState } from 'react'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload'
import PageHeader from '@/components/admin/PageHeader'
import {
createAdminFundExpense,
createAdminFundReturn,
fetchAdminFundMyAccount,
} from '@/services/admin'
import type {
AdminFundEntry,
AdminFundExpense,
AdminFundExpenseCategory,
AdminFundPayeeType,
} from '@/types/admin'
import type { UploadedFile } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
FUND_ACCOUNT_STATUS_LABELS,
FUND_CATEGORY_LABELS,
FUND_ENTRY_COLORS,
FUND_ENTRY_LABELS,
FUND_PAYEE_LABELS,
formatFundAmount,
toFen,
} from './fund-labels'
type ExpenseFormValues = {
amountYuan: number
category: AdminFundExpenseCategory
payeeType: AdminFundPayeeType
payeeName: string
payeeAccount?: string
platformName?: string
platformAccount?: string
orderId?: number
paidAt: dayjs.Dayjs
evidence: UploadedFile[]
note?: string
allowDuplicate?: boolean
}
type ReturnFormValues = {
amountYuan: number
occurredAt: dayjs.Dayjs
evidence: UploadedFile[]
note?: string
}
export default function FundSupportView() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [expenseOpen, setExpenseOpen] = useState(false)
const [returnOpen, setReturnOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [expenseForm] = Form.useForm<ExpenseFormValues>()
const [returnForm] = Form.useForm<ReturnFormValues>()
const myQuery = useQuery({
queryKey: ['admin-fund-my'],
queryFn: fetchAdminFundMyAccount,
})
const data = myQuery.data?.data
const account = data?.account || null
const entries = data?.entries || []
const expenses = data?.expenses || []
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'] })
}
function openExpense() {
expenseForm.resetFields()
expenseForm.setFieldsValue({
category: 'platform_recharge',
payeeType: 'platform',
paidAt: dayjs(),
evidence: [],
allowDuplicate: false,
})
setExpenseOpen(true)
}
function openReturn() {
returnForm.resetFields()
returnForm.setFieldsValue({
occurredAt: dayjs(),
evidence: [],
})
setReturnOpen(true)
}
async function submitExpense(values: ExpenseFormValues) {
if (!account) return
setSubmitting(true)
try {
await createAdminFundExpense(account.id, {
amountFen: toFen(values.amountYuan),
category: values.category,
payeeType: values.payeeType,
payeeName: values.payeeName,
payeeAccount: values.payeeAccount || '',
platformName: values.platformName || '',
platformAccount: values.platformAccount || '',
orderId: values.orderId || null,
paidAt: values.paidAt.toISOString(),
evidence: values.evidence || [],
note: values.note || '',
allowDuplicate: values.allowDuplicate === true,
})
message.success('支出已登记')
setExpenseOpen(false)
refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '登记支出失败')
} finally {
setSubmitting(false)
}
}
async function submitReturn(values: ReturnFormValues) {
if (!account) return
setSubmitting(true)
try {
await createAdminFundReturn(account.id, {
amountFen: toFen(values.amountYuan),
occurredAt: values.occurredAt.toISOString(),
evidence: values.evidence || [],
note: values.note || '',
})
message.success('缴回已提交')
setReturnOpen(false)
refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '缴回失败')
} finally {
setSubmitting(false)
}
}
const entryColumns: TableColumnsType<AdminFundEntry> = [
{
title: '时间',
dataIndex: 'occurredAt',
width: 170,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '类型',
dataIndex: 'entryType',
width: 110,
render: (value: AdminFundEntry['entryType']) => (
<Tag color={FUND_ENTRY_COLORS[value]}>{FUND_ENTRY_LABELS[value]}</Tag>
),
},
{
title: '金额',
dataIndex: 'amountFen',
width: 130,
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatFundAmount(value, true)}
</Typography.Text>
),
},
{
title: '余额',
dataIndex: 'balanceAfterFen',
width: 130,
render: (value: number) => formatFundAmount(value),
},
{
title: '凭证',
dataIndex: 'evidence',
width: 120,
render: (value: UploadedFile[]) => (
<ImagePreviewList files={value} size={36} maxVisible={2} />
),
},
{
title: '说明',
dataIndex: 'note',
render: (value: string) => value || '-',
},
]
const expenseColumns: TableColumnsType<AdminFundExpense> = [
{
title: '付款时间',
dataIndex: 'paidAt',
width: 170,
render: (value: string) => formatAdminDateTime(value),
},
{ title: '支出单号', dataIndex: 'expenseNo', width: 150 },
{
title: '分类',
dataIndex: 'category',
width: 120,
render: (value: AdminFundExpenseCategory) => FUND_CATEGORY_LABELS[value] || value,
},
{
title: '收款方',
dataIndex: 'payeeName',
width: 180,
render: (_value: string, row) => (
<Space direction="vertical" size={0}>
<span>{row.payeeName}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{FUND_PAYEE_LABELS[row.payeeType]}
{row.payeeAccount ? ` · ${row.payeeAccount}` : ''}
</Typography.Text>
</Space>
),
},
{
title: '平台/账号',
dataIndex: 'platformName',
width: 180,
render: (_value: string, row) =>
row.platformName || row.platformAccount
? `${row.platformName || '-'}${row.platformAccount ? ` / ${row.platformAccount}` : ''}`
: '-',
},
{
title: '关联订单',
dataIndex: 'orderId',
width: 110,
render: (value: number | null) => (value ? `#${value}` : '-'),
},
{
title: '金额',
dataIndex: 'amountFen',
width: 130,
render: (value: number) => (
<Typography.Text type="danger">{formatFundAmount(value)}</Typography.Text>
),
},
{
title: '凭证',
dataIndex: 'evidence',
width: 120,
render: (value: UploadedFile[]) => (
<ImagePreviewList files={value} size={36} maxVisible={2} />
),
},
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
]
return (
<section className="page-stack">
<PageHeader
title="我的备用金"
description="查看当前余额、登记每一笔支出;余额由流水自动计算,不能手工修改。"
extra={
<Button icon={<ReloadOutlined />} loading={myQuery.isFetching} onClick={refresh}>
刷新
</Button>
}
/>
{myQuery.error ? (
<Alert
type="error"
showIcon
message={myQuery.error instanceof Error ? myQuery.error.message : '读取我的备用金失败'}
/>
) : null}
{!account && !myQuery.isLoading ? (
<Alert
type="warning"
showIcon
message="还没有备用金账户"
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>
<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 }}
/>
),
},
]}
/>
</Card>
</>
) : null}
<Modal
title="登记备用金支出"
open={expenseOpen}
onCancel={() => setExpenseOpen(false)}
onOk={() => expenseForm.submit()}
confirmLoading={submitting}
destroyOnHidden
width={720}
>
<Form<ExpenseFormValues> form={expenseForm} layout="vertical" onFinish={submitExpense}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="amountYuan"
label="支出金额(元)"
rules={[{ required: true, message: '请输入支出金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="category"
label="支出分类"
rules={[{ required: true, message: '请选择分类' }]}
>
<Select
options={Object.entries(FUND_CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="payeeType"
label="收款方类型"
rules={[{ required: true, message: '请选择收款方类型' }]}
>
<Select
options={Object.entries(FUND_PAYEE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="payeeName"
label="收款方名称"
rules={[{ required: true, message: '请填写收款方' }]}
>
<Input placeholder="平台/供应商/客户名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="payeeAccount" label="收款账号">
<Input placeholder="微信号/支付宝/银行卡/平台账号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="platformName" label="充值/消费平台">
<Input placeholder="例如:91卡券、快手、游戏平台" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="platformAccount" label="平台账号">
<Input placeholder="充值时填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="orderId" label="关联订单 ID(可选)">
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="paidAt"
label="付款时间"
rules={[{ required: true, message: '请选择付款时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="evidence"
label="付款凭证"
rules={[{ required: true, message: '请上传付款截图' }]}
>
<ImageUpload scene="admin_fund_expense" scope="admin" maxCount={6} showPasteHint />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="note" label="用途/原因">
<Input.TextArea rows={2} placeholder="例如:给订单 123 充值、赔偿客户、补差价" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="allowDuplicate" valuePropName="checked">
<Checkbox>我已确认这不是重复支出(同一客服、金额、收款方 24 小时内)</Checkbox>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
title="缴回备用金"
open={returnOpen}
onCancel={() => setReturnOpen(false)}
onOk={() => returnForm.submit()}
confirmLoading={submitting}
destroyOnHidden
>
<Form<ReturnFormValues> form={returnForm} layout="vertical" onFinish={submitReturn}>
<Form.Item
name="amountYuan"
label="缴回金额(元)"
rules={[{ required: true, message: '请输入缴回金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="occurredAt"
label="缴回时间"
rules={[{ required: true, message: '请选择时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="evidence"
label="转账/交接凭证"
rules={[{ required: true, message: '请上传凭证' }]}
>
<ImageUpload scene="admin_fund_return" scope="admin" maxCount={4} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} placeholder="缴回原因或交接说明" />
</Form.Item>
</Form>
</Modal>
</section>
)
}
@@ -0,0 +1,77 @@
import type {
AdminFundEntryType,
AdminFundExpenseCategory,
AdminFundPayeeType,
AdminFundReconciliationStatus,
} from '@/types/admin'
export const FUND_ENTRY_LABELS: Record<AdminFundEntryType, string> = {
opening: '期初',
funding: '公司拨付',
issue: '发放',
expense: '支出',
return: '缴回',
adjustment: '调整',
}
export const FUND_ENTRY_COLORS: Record<AdminFundEntryType, string> = {
opening: 'default',
funding: 'blue',
issue: 'green',
expense: 'red',
return: 'orange',
adjustment: 'purple',
}
export const FUND_CATEGORY_LABELS: Record<AdminFundExpenseCategory, string> = {
platform_recharge: '平台充值',
purchase: '采购/拿货',
compensation: '客户赔偿',
freight: '运费',
other: '其他',
}
export const FUND_PAYEE_LABELS: Record<AdminFundPayeeType, string> = {
platform: '平台',
supplier: '供应商',
customer: '客户',
other: '其他',
}
export const FUND_ACCOUNT_STATUS_LABELS: Record<string, string> = {
active: '正常',
frozen: '冻结',
closed: '关闭',
}
export const FUND_RECONCILIATION_STATUS_LABELS: Record<AdminFundReconciliationStatus, string> = {
pending: '待确认',
confirmed: '已对平',
discrepancy: '有差异',
}
export const FUND_RECONCILIATION_STATUS_COLORS: Record<AdminFundReconciliationStatus, string> = {
pending: 'gold',
confirmed: 'green',
discrepancy: 'red',
}
export function formatFundAmount(amountFen: number, withSign = false) {
const value = Number(amountFen || 0) / 100
const absolute = Math.abs(value).toFixed(2)
if (withSign) {
if (value > 0) return `+¥${absolute}`
if (value < 0) return `-¥${absolute}`
}
return value < 0 ? `-¥${absolute}` : `¥${absolute}`
}
export function toFen(value: unknown) {
const yuan = Number(value)
return Number.isFinite(yuan) ? Math.round(yuan * 100) : 0
}
export function fromFen(value: unknown) {
const fen = Number(value)
return Number.isFinite(fen) ? fen / 100 : 0
}
+105
View File
@@ -0,0 +1,105 @@
import { apiGet, apiPatch, apiPost } from '@/lib/http'
import type {
AdminFundAccountDetailResult,
AdminFundAccountListResult,
AdminFundExpenseListResult,
AdminFundMyAccountResult,
AdminFundOverview,
AdminFundReconciliationListResult,
AdminFundSetupOptions,
} from '@/types/admin'
export function fetchAdminFundOverview() {
return apiGet<AdminFundOverview>('/api/v1/admin/fund/overview')
}
export function fetchAdminFundMyAccount() {
return apiGet<AdminFundMyAccountResult>('/api/v1/admin/fund/my')
}
export function fetchAdminFundAccounts(params?: Record<string, unknown>) {
return apiGet<AdminFundAccountListResult>('/api/v1/admin/fund/accounts', params)
}
export function fetchAdminFundAccountDetail(accountId: number, params?: Record<string, unknown>) {
return apiGet<AdminFundAccountDetailResult>(`/api/v1/admin/fund/accounts/${accountId}`, params)
}
export function fetchAdminFundSupervisors() {
return apiGet<{ items: AdminFundAccountListResult['items'] }>('/api/v1/admin/fund/supervisors')
}
export function fetchAdminFundSetupOptions() {
return apiGet<AdminFundSetupOptions>('/api/v1/admin/fund/options')
}
export function createAdminFundAccount(payload: Record<string, unknown>) {
return apiPost<{ account: AdminFundAccountListResult['items'][number] }>(
'/api/v1/admin/fund/accounts',
payload,
)
}
export function ensureAdminFundAgentAccounts(payload: Record<string, unknown>) {
return apiPost<{
createdCount: number
items: Array<{ id: number; accountNo: string; ownerName: string }>
}>('/api/v1/admin/fund/accounts/ensure-agents', payload)
}
export function updateAdminFundAccount(accountId: number, payload: Record<string, unknown>) {
return apiPatch<{ account: AdminFundAccountListResult['items'][number] }>(
`/api/v1/admin/fund/accounts/${accountId}`,
payload,
)
}
export function createAdminFundFunding(accountId: number, payload: Record<string, unknown>) {
return apiPost<Record<string, unknown>>(
`/api/v1/admin/fund/accounts/${accountId}/funding`,
payload,
)
}
export function createAdminFundIssue(accountId: number, payload: Record<string, unknown>) {
return apiPost<Record<string, unknown>>(`/api/v1/admin/fund/accounts/${accountId}/issue`, payload)
}
export function createAdminFundReturn(accountId: number, payload: Record<string, unknown>) {
return apiPost<Record<string, unknown>>(
`/api/v1/admin/fund/accounts/${accountId}/return`,
payload,
)
}
export function createAdminFundExpense(accountId: number, payload: Record<string, unknown>) {
return apiPost<{ expense: AdminFundExpenseListResult['items'][number] }>(
`/api/v1/admin/fund/accounts/${accountId}/expenses`,
payload,
)
}
export function fetchAdminFundExpenses(params?: Record<string, unknown>) {
return apiGet<AdminFundExpenseListResult>('/api/v1/admin/fund/expenses', params)
}
export function fetchAdminFundReconciliations(params?: Record<string, unknown>) {
return apiGet<AdminFundReconciliationListResult>('/api/v1/admin/fund/reconciliations', params)
}
export function createAdminFundReconciliation(payload: Record<string, unknown>) {
return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>(
'/api/v1/admin/fund/reconciliations',
payload,
)
}
export function confirmAdminFundReconciliation(
reconciliationId: number,
payload: Record<string, unknown>,
) {
return apiPost<{ reconciliation: AdminFundReconciliationListResult['items'][number] }>(
`/api/v1/admin/fund/reconciliations/${reconciliationId}/confirm`,
payload,
)
}
@@ -1,6 +1,7 @@
export * from './auth'
export * from './dashboard'
export * from './finance'
export * from './fund'
export * from './users'
export * from './audit-logs'
export * from './platform-config'
@@ -35,6 +35,7 @@ export function fetchAdminUsers(params?: Record<string, unknown>) {
export function createAdminUser(payload: {
username: string
displayName?: string
password: string
role: string
status?: string
@@ -42,6 +43,13 @@ export function createAdminUser(payload: {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
export function updateAdminUserDisplayName(
userId: number | string,
payload: { displayName: string },
) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/display-name`, payload)
}
export function updateAdminUserRole(userId: number | string, payload: { role: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload)
}
@@ -4,6 +4,7 @@ export interface AdminAuditLogItem {
logId: number
actorUserId: number
actorUsername: string
actorDisplayName: string
actorRole: AdminRole
action: string
targetType: string
+2
View File
@@ -7,6 +7,7 @@ export interface AdminLoginResponse {
user: {
userId: number
username: string
displayName: string
role: AdminRole
permissions?: string[]
}
@@ -18,6 +19,7 @@ export interface AdminSessionSummary {
user: {
userId: number
username: string
displayName: string
role: AdminRole
permissions?: string[]
}
+160
View File
@@ -0,0 +1,160 @@
import type { UploadedFile } from '@/types/worker-platform'
export type AdminFundAccountType = 'supervisor' | 'agent'
export type AdminFundAccountStatus = 'active' | 'frozen' | 'closed'
export type AdminFundEntryType =
| 'opening'
| 'funding'
| 'issue'
| 'expense'
| 'return'
| 'adjustment'
export type AdminFundExpenseCategory =
| 'platform_recharge'
| 'purchase'
| 'compensation'
| 'freight'
| 'other'
export type AdminFundPayeeType = 'platform' | 'supplier' | 'customer' | 'other'
export type AdminFundReconciliationStatus = 'pending' | 'confirmed' | 'discrepancy'
export type AdminFundAccount = {
id: number
accountNo: string
accountType: AdminFundAccountType
ownerAdminUserId: number
ownerName: string
responsibleSupervisorId: number | null
responsibleSupervisorName: string
balanceFen: number
status: AdminFundAccountStatus
monthInFen: number
monthOutFen: number
lastEntryAt: string | null
lastReconciledAt: string | null
createdAt: string
updatedAt: string
}
export type AdminFundEntry = {
id: number
entryNo: string
accountId: number
entryType: AdminFundEntryType
amountFen: number
balanceAfterFen: number
transferNo: string
counterpartyAccountId: number | null
counterpartyName: string
reconciliationId: number | null
occurredAt: string
createdByAdminUserId: number | null
createdByUsername: string
evidence: UploadedFile[]
note: string
createdAt: string
}
export type AdminFundExpense = {
id: number
expenseNo: string
accountId: number
accountNo: string
agentName: string
amountFen: number
category: AdminFundExpenseCategory
payeeType: AdminFundPayeeType
payeeName: string
payeeAccount: string
platformName: string
platformAccount: string
orderId: number | null
orderItemId: number | null
paidAt: string
evidence: UploadedFile[]
note: string
fundEntryId: number
createdByAdminUserId: number | null
createdByUsername: string
createdAt: string
updatedAt: string
}
export type AdminFundReconciliation = {
id: number
reconciliationNo: string
accountId: number
accountNo: string
agentName: string
periodStart: string
periodEnd: string
systemBalanceFen: number
actualBalanceFen: number
differenceFen: number
status: AdminFundReconciliationStatus
reason: string
evidence: UploadedFile[]
confirmedByAdminUserId: number | null
confirmedByUsername: string
confirmedAt: string | null
createdByAdminUserId: number | null
createdByUsername: string
createdAt: string
updatedAt: string
}
export type AdminFundOverview = {
supervisorBalanceFen: number
agentBalanceFen: number
totalBalanceFen: number
activeSupervisorCount: number
activeAgentCount: number
monthIssuedFen: number
monthExpenseFen: number
monthReturnedFen: number
pendingReconciliationCount: number
discrepancyCount: number
unreconciledAgentCount: number
}
export type AdminFundAccountListResult = {
items: AdminFundAccount[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminFundAccountDetailResult = {
account: AdminFundAccount
items: AdminFundEntry[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminFundMyAccountResult = {
account: AdminFundAccount | null
entries: AdminFundEntry[]
expenses: AdminFundExpense[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminFundExpenseListResult = {
items: AdminFundExpense[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminFundReconciliationListResult = {
items: AdminFundReconciliation[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminFundSetupUser = {
id: number
username: string
displayName: string
role: 'admin' | 'operator' | 'support' | 'finance'
status: string
}
export type AdminFundSetupOptions = {
users: AdminFundSetupUser[]
supervisorAccounts: AdminFundAccount[]
agentAccounts: AdminFundAccount[]
}
+22
View File
@@ -23,6 +23,28 @@ export type {
AdminFinanceTransactionResult,
} from './finance'
// Fund / imprest account types
export type {
AdminFundAccount,
AdminFundAccountDetailResult,
AdminFundAccountListResult,
AdminFundAccountStatus,
AdminFundAccountType,
AdminFundEntry,
AdminFundEntryType,
AdminFundExpense,
AdminFundExpenseCategory,
AdminFundExpenseListResult,
AdminFundMyAccountResult,
AdminFundOverview,
AdminFundPayeeType,
AdminFundReconciliation,
AdminFundReconciliationListResult,
AdminFundReconciliationStatus,
AdminFundSetupOptions,
AdminFundSetupUser,
} from './fund'
// Login logs
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
@@ -2,6 +2,7 @@ export interface AdminLoginLogItem {
logId: number
userId: number | null
username: string
displayName: string
role: string
ip: string
location: string
+1
View File
@@ -250,6 +250,7 @@ export interface AdminTaskDetail {
completedBy: null | {
userId: number
username: string
displayName: string
role: string
}
}
+1
View File
@@ -3,6 +3,7 @@ import type { AdminRole, AdminUserStatus } from './auth'
export interface AdminUserListItem {
userId: number
username: string
displayName: string
role: AdminRole
status: AdminUserStatus
createdAt: string
+18
View File
@@ -2,6 +2,7 @@ const ADMIN_TOKEN_KEY = 'order-site-admin-token'
const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at'
const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name'
const ADMIN_ROLE_KEY = 'order-site-admin-role'
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
@@ -29,6 +30,19 @@ export function getAdminUsername() {
return localStorage.getItem(ADMIN_USERNAME_KEY) || ''
}
export function getAdminDisplayName() {
return localStorage.getItem(ADMIN_DISPLAY_NAME_KEY) || ''
}
export function setAdminDisplayName(displayName: string) {
const normalized = String(displayName || '').trim()
if (normalized) {
localStorage.setItem(ADMIN_DISPLAY_NAME_KEY, normalized)
} else {
localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY)
}
}
export function getAdminRole() {
const role = localStorage.getItem(ADMIN_ROLE_KEY)
if (role === 'admin' || role === 'operator' || role === 'support' || role === 'finance') {
@@ -94,6 +108,7 @@ export function setAdminSession(
user?: {
userId: number
username: string
displayName?: string
role: 'admin' | 'operator' | 'support' | 'finance'
permissions?: string[]
},
@@ -109,6 +124,8 @@ export function setAdminSession(
localStorage.setItem(ADMIN_USERNAME_KEY, user.username)
}
setAdminDisplayName(user?.displayName || user?.username || '')
if (user?.role) {
localStorage.setItem(ADMIN_ROLE_KEY, user.role)
}
@@ -121,6 +138,7 @@ export function clearAdminSession() {
localStorage.removeItem(ADMIN_EXPIRES_AT_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem(ADMIN_USERNAME_KEY)
localStorage.removeItem(ADMIN_DISPLAY_NAME_KEY)
localStorage.removeItem(ADMIN_ROLE_KEY)
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
}