feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { Button, Typography } from 'antd';
import MatchSelector from './MatchSelector';
import { GAP, LEFT_WIDTH, ROW_HEIGHT } from './JinshujuMatchModal.types';
import type { JinshujuEntryRow, MatchDecision, StudentOption } from './JinshujuMatchModal.types';
const { Text } = Typography;
interface MatchStepProps {
entries: JinshujuEntryRow[];
studentOptions: StudentOption[];
getDecision: (serial: number) => MatchDecision | undefined;
onDecisionChange: (serial: number, d: MatchDecision) => void;
onClear: () => void;
leftRef: React.RefObject<HTMLDivElement | null>;
rightRef: React.RefObject<HTMLDivElement | null>;
onScroll: (source: 'left' | 'right') => void;
total: number;
matched: number;
}
const MatchStep: React.FC<MatchStepProps> = ({
entries,
studentOptions,
getDecision,
onDecisionChange,
onClear,
leftRef,
rightRef,
onScroll,
total,
matched,
}) => {
const svgHeight = entries.length * ROW_HEIGHT;
const lines: React.ReactNode[] = [];
entries.forEach((entry, i) => {
const y = i * ROW_HEIGHT + ROW_HEIGHT / 2;
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
const color = isMatched ? '#1677ff' : '#d9d9d9';
lines.push(
<line
key={entry.serialNumber}
x1={LEFT_WIDTH}
y1={y}
x2={LEFT_WIDTH + GAP}
y2={y}
stroke={color}
strokeWidth={isMatched ? 2 : 1}
strokeDasharray={isMatched ? undefined : '4 4'}
opacity={isMatched ? 0.7 : 0.3}
/>,
);
});
return (
<div style={{ position: 'relative' }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary">
{total} {matched}
</Text>
<Button size="small" onClick={onClear}>
</Button>
</div>
<div style={{ display: 'flex', position: 'relative' }}>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: LEFT_WIDTH + GAP,
height: svgHeight,
pointerEvents: 'none',
zIndex: 1,
}}
>
{lines}
</svg>
<div
ref={leftRef}
onScroll={() => onScroll('left')}
style={{ width: LEFT_WIDTH, maxHeight: 480, overflowY: 'auto', flexShrink: 0 }}
>
{entries.map((entry, i) => {
const d = getDecision(entry.serialNumber);
const isMatched = d?.action === 'match';
return (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',
borderLeft: isMatched ? '3px solid #1677ff' : '3px solid transparent',
}}
>
<Text strong style={{ fontSize: 13 }}>
{entry.name || <Text type="secondary"></Text>}
</Text>
{entry.phone && (
<Text type="secondary" style={{ fontSize: 12 }}>
{entry.phone}
</Text>
)}
<Text type="secondary" style={{ fontSize: 11 }}>
#{entry.serialNumber}
</Text>
</div>
);
})}
</div>
<div style={{ width: GAP, flexShrink: 0 }} />
<div
ref={rightRef}
onScroll={() => onScroll('right')}
style={{ flex: 1, maxHeight: 480, overflowY: 'auto' }}
>
{entries.map((entry) => (
<div
key={entry.serialNumber}
style={{
height: ROW_HEIGHT,
padding: '8px 12px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<MatchSelector
entry={entry}
decision={getDecision(entry.serialNumber)}
studentOptions={studentOptions}
onChange={(newD) => onDecisionChange(entry.serialNumber, newD)}
/>
</div>
))}
</div>
</div>
</div>
);
};
export default MatchStep;