127 lines
7.8 KiB
TypeScript
127 lines
7.8 KiB
TypeScript
import { CheckOutlined, CloseOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons';
|
|
import { Alert, App, Button, Card, Descriptions, Drawer, Flex, Form, Input, Space, Table, Tabs, Tag, Typography } from 'antd';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import type { components } from '../api/schema.generated';
|
|
import { platformRequest } from '../api/platform';
|
|
import { useAuth } from '../auth/AuthProvider';
|
|
|
|
type Approval = components['schemas']['PlatformApprovalRequestItem'];
|
|
type Policy = components['schemas']['PlatformApprovalPolicyItem'];
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
'0': '待审批', '1': '已批准', '2': '已拒绝', '3': '执行中', '4': '成功', '5': '失败', '6': '已撤销', '7': '已过期',
|
|
Pending: '待审批', Approved: '已批准', Rejected: '已拒绝', Executing: '执行中', Succeeded: '成功', Failed: '失败', Cancelled: '已撤销', Expired: '已过期',
|
|
};
|
|
|
|
function statusColor(status: unknown) {
|
|
const value = String(status);
|
|
if (['4', 'Succeeded'].includes(value)) return 'green';
|
|
if (['5', 'Failed', '2', 'Rejected'].includes(value)) return 'red';
|
|
if (['0', 'Pending'].includes(value)) return 'gold';
|
|
if (['3', 'Executing'].includes(value)) return 'blue';
|
|
return 'default';
|
|
}
|
|
|
|
export function ApprovalCenterPage() {
|
|
const auth = useAuth();
|
|
const { message, modal } = App.useApp();
|
|
const [items, setItems] = useState<Approval[]>([]);
|
|
const [policies, setPolicies] = useState<Policy[]>([]);
|
|
const [selected, setSelected] = useState<Approval | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [form] = Form.useForm<{ reason: string }>();
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [requests, policyItems] = await Promise.all([
|
|
platformRequest<Approval[]>('GET', '/api/platform-admin/approvals', { query: { limit: 200 } }),
|
|
platformRequest<Policy[]>('GET', '/api/platform-admin/approvals/policies'),
|
|
]);
|
|
setItems(requests);
|
|
setPolicies(policyItems);
|
|
setSelected((current) => current ? requests.find((item) => item.id === current.id) ?? null : null);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '审批中心加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [message]);
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
const decide = async (action: 'approve' | 'reject' | 'cancel') => {
|
|
if (!selected) return;
|
|
const { reason } = await form.validateFields();
|
|
modal.confirm({
|
|
title: action === 'approve' ? '确认批准并执行' : action === 'reject' ? '确认拒绝' : '确认撤销',
|
|
content: action === 'approve' ? '批准时会重新校验权限和目标状态,成功后立即执行。' : '该决定会写入不可省略的平台审计日志。',
|
|
okButtonProps: { danger: action !== 'approve' },
|
|
onOk: async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await platformRequest<Approval>('POST', `/api/platform-admin/approvals/{requestId}/${action}`, {
|
|
path: { requestId: selected.id }, body: { reason },
|
|
});
|
|
setSelected(result);
|
|
form.resetFields();
|
|
message.success(action === 'approve' ? '审批已批准,等待 Worker 执行' : '审批状态已更新');
|
|
await load();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '审批操作失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const columns = useMemo(() => [
|
|
{ title: '审批单', dataIndex: 'requestNo', key: 'requestNo' },
|
|
{ title: '策略', dataIndex: 'policyCode', key: 'policyCode' },
|
|
{ title: '目标', key: 'target', render: (_: unknown, row: Approval) => `${row.targetType} / ${row.targetId}` },
|
|
{ title: '金额', dataIndex: 'amountCents', key: 'amount', render: (value: unknown) => value == null ? '-' : `¥${(Number(value) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}` },
|
|
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: unknown) => <Tag color={statusColor(value)}>{statusLabels[String(value)] ?? String(value)}</Tag> },
|
|
{ title: '提交时间', dataIndex: 'createdAt', key: 'createdAt', render: (value: string) => new Date(value).toLocaleString('zh-CN') },
|
|
], []);
|
|
|
|
const pending = selected && ['0', 'Pending'].includes(String(selected.status));
|
|
const canDecide = pending && selected.requestedBy !== auth.user?.userId && auth.hasPermission('platform:approval:decide');
|
|
const canCancel = pending && selected.requestedBy === auth.user?.userId;
|
|
|
|
return (
|
|
<div className="business-page">
|
|
<Flex justify="space-between" align="end" gap={16} wrap>
|
|
<div><Typography.Text type="secondary">平台治理</Typography.Text><Typography.Title level={2}>审批中心</Typography.Title><Typography.Paragraph type="secondary">统一处理大额财务、租户终止、超级权限和支付渠道变更。</Typography.Paragraph></div>
|
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void load()}>刷新</Button>
|
|
</Flex>
|
|
<Tabs items={[
|
|
{ key: 'requests', label: '审批任务', children: <Card><Table rowKey="id" loading={loading} dataSource={items} columns={columns} pagination={{ pageSize: 20 }} onRow={(record) => ({ onClick: () => { setSelected(record); form.resetFields(); } })} /></Card> },
|
|
{ key: 'policies', label: '审批策略', children: <Card><Table rowKey="id" dataSource={policies} pagination={false} columns={[
|
|
{ title: '策略', dataIndex: 'name' }, { title: '编码', dataIndex: 'code' },
|
|
{ title: '规则', render: (_: unknown, row: Policy) => row.alwaysRequireApproval ? '始终审批' : row.amountThresholdCents ? `达到 ¥${(Number(row.amountThresholdCents) / 100).toLocaleString('zh-CN')}` : '即时执行' },
|
|
{ title: '版本', dataIndex: 'version' }, { title: '有效期', dataIndex: 'expiresAfterHours', render: (value: unknown) => `${String(value)} 小时` },
|
|
]} /></Card> },
|
|
]} />
|
|
<Drawer title={selected?.requestNo} width={680} open={Boolean(selected)} onClose={() => setSelected(null)}>
|
|
{selected && <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
{selected.error && <Alert type="error" showIcon message="执行失败" description={selected.error} />}
|
|
<Descriptions bordered size="small" column={1} items={[
|
|
{ key: 'status', label: '状态', children: <Tag color={statusColor(selected.status)}>{statusLabels[String(selected.status)] ?? String(selected.status)}</Tag> },
|
|
{ key: 'command', label: '命令', children: selected.commandType },
|
|
{ key: 'target', label: '目标', children: `${selected.targetType} / ${selected.targetId}` },
|
|
{ key: 'reason', label: '申请原因', children: selected.requestReason || '-' },
|
|
{ key: 'expires', label: '审批期限', children: new Date(selected.expiresAt).toLocaleString('zh-CN') },
|
|
{ key: 'snapshot', label: '脱敏快照', children: <pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(selected.requestSnapshot, null, 2)}</pre> },
|
|
]} />
|
|
{(canDecide || canCancel) && <Form form={form} layout="vertical"><Form.Item name="reason" label="决定原因" rules={[{ required: true, min: 3 }]}><Input.TextArea rows={3} /></Form.Item></Form>}
|
|
<Flex justify="end" gap={8}>
|
|
{canCancel && <Button danger icon={<StopOutlined />} onClick={() => void decide('cancel')}>撤销申请</Button>}
|
|
{canDecide && <><Button danger icon={<CloseOutlined />} onClick={() => void decide('reject')}>拒绝</Button><Button type="primary" icon={<CheckOutlined />} onClick={() => void decide('approve')}>批准并执行</Button></>}
|
|
</Flex>
|
|
</Space>}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|