由 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)
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
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;
|