Files
gongxue-base/apps/admin/src/components/JinshujuMatchModal.tsx
wangziqi a32c0a0731 fix(admin): 前端功能与安全修复(角色勾选/月视图新建/附件预览/考勤 late 等)
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用
- JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref
- AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护
- 考勤 late 计入出勤率与主状态

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useImmer } from 'use-immer';
import { App, Button, Form, Input, Modal, Select, Spin, Steps, Typography } from 'antd';
import { CloudUploadOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
import { usePermission } from '../hooks/usePermission';
import PermissionButton from './PermissionButton';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate';
import { jinshujuRulesSchema } from '../api/schemas';
import MatchStep from './MatchStep';
import RuleEditor from './RuleEditor';
import type {
JinshujuEntryRow,
JinshujuFormField,
MatchDecision,
MatchRule,
PreviewResponse,
StudentOption,
} from './JinshujuMatchModal.types';
const { Text } = Typography;
interface MatchModalProps {
open: boolean;
onClose: () => void;
onApplied: () => void;
}
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { modal } = App.useApp();
const { hasPermission, hasAllPermissions, permissionsReady } = usePermission();
const canTriggerSync = hasPermission('sync:trigger');
const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger');
const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection');
const [loading, setLoading] = useState(false);
const [selectedRuleId, setSelectedRuleId] = useState<number | undefined>();
const [editingRule, setEditingRule] = useState<MatchRule | null>(null);
const [showRuleEditor, setShowRuleEditor] = useState(false);
const [credForm] = Form.useForm();
const formToken = Form.useWatch('formToken', credForm) ?? '';
const [entries, setEntries] = useState<JinshujuEntryRow[]>([]);
const [studentOptions, setStudentOptions] = useState<StudentOption[]>([]);
const [decisions, setDecisions] = useImmer<Map<number, MatchDecision>>(new Map());
const leftRef = useRef<HTMLDivElement>(null);
const rightRef = useRef<HTMLDivElement>(null);
const [formFields, setFormFields] = useState<JinshujuFormField[]>([]);
const [formName, setFormName] = useState('');
const [scrollTop, setScrollTop] = useState(0);
const {
data: rules = [],
refetch: refetchRules,
} = useQuery<MatchRule[]>({
queryKey: ['sync', 'jinshuju', 'rules'],
enabled: open && canEnterModal,
queryFn: async () => {
try {
const res = await api.get<{ success: boolean; data: MatchRule[] }>(
'/sync/jinshuju/rules',
);
return res.success ? validateResponse<MatchRule[]>(jinshujuRulesSchema, res.data) : [];
} catch {
return [];
}
},
});
const loadRules = useCallback(() => refetchRules(), [refetchRules]);
const deleteRuleMutation = useApiMutation(
async (id: number) => api.delete(`/sync/jinshuju/rules/${id}`),
{ invalidate: [['sync', 'jinshuju', 'rules']] },
);
const handleConnectionNext = async () => {
if (!canTriggerSync) return;
try {
const values = await credForm.validateFields();
setLoading(true);
const response = await api.post<{
success: boolean;
data: { name: string; fields: JinshujuFormField[] };
}>('/sync/jinshuju/fields', values);
setFormFields(response.data.fields);
setFormName(response.data.name);
setStep('rule');
} catch (error: unknown) {
const apiError = error as { message?: string; errorFields?: unknown[] };
if (!apiError.errorFields && apiError.message) message.error(apiError.message);
} finally {
setLoading(false);
}
};
const handlePreview = async () => {
if (!canTriggerSync) return;
try {
const values = await credForm.validateFields();
setLoading(true);
const body: Record<string, unknown> = { ...values };
if (selectedRuleId) body.ruleId = selectedRuleId;
const res = await api.post<PreviewResponse>('/sync/jinshuju/preview', body);
if (!res.success) throw new Error('预览失败');
setEntries(res.entries);
setStudentOptions(res.students);
const initial = new Map<number, MatchDecision>();
for (const entry of res.entries) {
if (entry.suggestedStudent) {
initial.set(entry.serialNumber, {
action: 'match',
matchStudentId: entry.suggestedStudent.id,
});
}
}
setDecisions(initial);
setStep('match');
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally {
setLoading(false);
}
};
const handleApply = async () => {
if (!canTriggerSync) return;
setLoading(true);
setStep('applying');
try {
const decisionList = [...decisions.entries()].map(([serialNumber, d]) => ({
serialNumber,
...d,
}));
const body: Record<string, unknown> = {
...credForm.getFieldsValue(),
decisions: decisionList,
};
if (selectedRuleId) body.ruleId = selectedRuleId;
const res = await api.post<{
success: boolean;
log: { recordsCount: number; message?: string };
}>('/sync/jinshuju/apply', body);
if (res.success) {
message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);
onApplied();
reset();
} else {
// 接口返回 success:false 时也要结束「处理中」并给出错误提示
message.error(res.log?.message || '处理失败,请检查后重试');
setStep('match');
}
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
setStep('match');
} finally {
setLoading(false);
}
};
const reset = () => {
setStep('connection');
setEntries([]);
setStudentOptions([]);
setDecisions(new Map());
setSelectedRuleId(undefined);
setFormFields([]);
setFormName('');
setShowRuleEditor(false);
credForm.resetFields();
};
const handleClose = () => {
// 已进入匹配步骤且存在待处理数据时,关闭会丢失全部决策,需先确认
const hasPendingWork = step === 'match' && (entries.length > 0 || decisions.size > 0);
if (hasPendingWork) {
modal.confirm({
title: '放弃当前匹配?',
content: '已确认的匹配决策将全部丢失,且不会写入系统。',
okText: '放弃',
okButtonProps: { danger: true },
cancelText: '继续匹配',
onOk: () => {
reset();
onClose();
},
});
return;
}
reset();
onClose();
};
const handleScroll = (source: 'left' | 'right') => {
const el = source === 'left' ? leftRef.current : rightRef.current;
if (el) setScrollTop(el.scrollTop);
};
useEffect(() => {
if (leftRef.current) leftRef.current.scrollTop = scrollTop;
if (rightRef.current) rightRef.current.scrollTop = scrollTop;
}, [scrollTop]);
const getDecision = (serial: number): MatchDecision | undefined => decisions.get(serial);
const setDecision = (serial: number, d: MatchDecision) =>
setDecisions((draft) => {
draft.set(serial, d);
});
const total = entries.length;
const matched = [...decisions.values()].filter((d) => d.action !== 'skip').length;
const visibleRules = rules.filter((rule) => rule.formToken === formToken);
const selectedRule = visibleRules.find((rule) => rule.id === selectedRuleId) ?? null;
// ── Render ──
const renderConnectionStep = () => (
<Form form={credForm} layout="vertical" style={{ marginTop: 24 }}>
<Form.Item
name="apiKey"
label="API Key"
extra="金数据个人中心 → API 中获取"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input placeholder="请输入 API Key" autoComplete="username" />
</Form.Item>
<Form.Item
name="apiSecret"
label="API Secret"
rules={[{ required: true, message: '请输入 API Secret' }]}
>
<Input.Password placeholder="请输入 API Secret" autoComplete="current-password" />
</Form.Item>
<Form.Item
name="formToken"
label="表单 Token"
extra="例如表单地址 /f/AbC123 中的 AbC123"
rules={[{ required: true, message: '请输入表单 Token' }]}
>
<Input placeholder="请输入表单 Token" />
</Form.Item>
</Form>
);
const renderRuleStep = () => (
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 12 }}>
<Text strong></Text>
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
{formName} {formFields.length}
</Text>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Select
style={{ flex: 1 }}
placeholder="默认规则field_1 → 姓名field_2 → 手机号"
allowClear
value={selectedRuleId}
onChange={(value) => setSelectedRuleId(value)}
options={visibleRules.map((rule) => ({ value: rule.id, label: rule.name }))}
/>
{canTriggerSync && selectedRule ? (
<Button
icon={<EditOutlined />}
onClick={() => {
setEditingRule(selectedRule);
setShowRuleEditor(true);
}}
>
</Button>
) : null}
{canTriggerSync ? (
<Button
icon={<PlusOutlined />}
onClick={() => {
setEditingRule(null);
setShowRuleEditor(true);
}}
>
</Button>
) : null}
</div>
{visibleRules.length === 0 && !showRuleEditor ? (
<Text type="secondary" style={{ display: 'block', marginTop: 12 }}>
</Text>
) : null}
{canTriggerSync && showRuleEditor ? (
<RuleEditor
rule={editingRule}
formToken={formToken}
fields={formFields}
onSave={() => {
setShowRuleEditor(false);
loadRules();
}}
onDelete={async (id) => {
try {
await deleteRuleMutation.mutateAsync(id);
message.success('规则已删除');
if (selectedRuleId === id) setSelectedRuleId(undefined);
setShowRuleEditor(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
onCancel={() => setShowRuleEditor(false)}
/>
) : null}
</div>
);
const renderMatchStep = () => {
return (
<MatchStep
entries={entries}
studentOptions={studentOptions}
getDecision={getDecision}
onDecisionChange={setDecision}
onClear={() => setDecisions(new Map())}
leftRef={leftRef}
rightRef={rightRef}
onScroll={handleScroll}
total={total}
matched={matched}
/>
);
};
const backCancelButtons = (onBack: () => void) => [
<Button key="back" onClick={onBack}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
];
const currentStep = step === 'connection' ? 0 : step === 'rule' ? 1 : 2;
return (
<Modal
title="同步金数据"
open={open && canEnterModal}
onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640}
mask={{ closable: false }}
closable={step !== 'applying'}
footer={
step === 'connection'
? [
<Button key="cancel" onClick={handleClose}>
</Button>,
<Button key="next" type="primary" onClick={handleConnectionNext}>
</Button>,
]
: step === 'rule'
? [
...backCancelButtons(() => setStep('connection')),
<Button
key="next"
type="primary"
icon={<SearchOutlined />}
loading={loading}
onClick={handlePreview}
>
</Button>,
]
: step === 'match'
? [
...backCancelButtons(() => setStep('rule')),
canTriggerSync ? (
<PermissionButton
key="apply"
permission="sync:trigger"
type="primary"
icon={<CloudUploadOutlined />}
loading={loading}
onClick={handleApply}
>
</PermissionButton>
) : null,
]
: null
}
>
<Steps
current={currentStep}
items={[{ title: '连接表单' }, { title: '匹配规则' }, { title: '确认匹配' }]}
/>
{step === 'connection' ? renderConnectionStep() : null}
{step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? (
<>
<Spin description="正在同步,请勿关闭窗口..." style={{ display: 'block', margin: '48px auto' }} />
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>
</Typography.Text>
</>
) : null}
</Modal>
);
};
export default JinshujuMatchModal;