feat: 新增客服备用金台账与后台用户中文显示名
- 新增备用金账户、流水、支出、对账与权限 - 后台用户支持中文显示名并同步备用金姓名 - 审计、登录记录、任务及工单操作人优先展示中文名 - 补充备用金与人工支出方案文档
This commit is contained in:
@@ -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 || '-'}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user