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 = ({ 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(); const [editingRule, setEditingRule] = useState(null); const [showRuleEditor, setShowRuleEditor] = useState(false); const [credForm] = Form.useForm(); const formToken = Form.useWatch('formToken', credForm) ?? ''; const [entries, setEntries] = useState([]); const [studentOptions, setStudentOptions] = useState([]); const [decisions, setDecisions] = useImmer>(new Map()); const leftRef = useRef(null); const rightRef = useRef(null); const [formFields, setFormFields] = useState([]); const [formName, setFormName] = useState(''); const [scrollTop, setScrollTop] = useState(0); const { data: rules = [], refetch: refetchRules, } = useQuery({ 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(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 = { ...values }; if (selectedRuleId) body.ruleId = selectedRuleId; const res = await api.post('/sync/jinshuju/preview', body); if (!res.success) throw new Error('预览失败'); setEntries(res.entries); setStudentOptions(res.students); const initial = new Map(); 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 = { ...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 = () => (
); const renderRuleStep = () => (
选择匹配规则 已连接表单「{formName}」,共 {formFields.length} 个字段。选择已有规则或新建字段映射。