fix: align permission-gated UI actions

This commit is contained in:
2026-07-23 11:32:13 +08:00
parent adf738288f
commit c98d37307e
28 changed files with 1340 additions and 718 deletions

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Result } from 'antd';
import { Result, Spin } from 'antd';
import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
const DefaultRoute: React.FC = () => {
const { permissions } = usePermission();
const { permissions, permissionsReady } = usePermission();
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
const roles = (() => {
try {
return JSON.parse(localStorage.getItem('user') || '{}').roles || [];

View File

@@ -11,6 +11,8 @@ import {
} from '@ant-design/icons';
import api from '../api';
import { message } from '../ui/app-message';
import { usePermission } from '../hooks/usePermission';
import PermissionButton from './PermissionButton';
const { Text } = Typography;
@@ -86,7 +88,12 @@ interface MatchSelectorProps {
onChange: (d: MatchDecision) => void;
}
const MatchSelector: React.FC<MatchSelectorProps> = ({ entry, decision, studentOptions, onChange }) => {
const MatchSelector: React.FC<MatchSelectorProps> = ({
entry,
decision,
studentOptions,
onChange,
}) => {
const action = decision?.action ?? 'skip';
if (action === 'match') {
@@ -94,7 +101,9 @@ const MatchSelector: React.FC<MatchSelectorProps> = ({ entry, decision, studentO
const matchedStudent = studentOptions.find((s) => s.id === matchD.matchStudentId);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="blue" icon={<LinkOutlined />}></Tag>
<Tag color="blue" icon={<LinkOutlined />}>
</Tag>
<Text style={{ flex: 1 }}>
{matchedStudent?.name ?? '未知'}
{matchedStudent?.studentNo && (
@@ -103,7 +112,9 @@ const MatchSelector: React.FC<MatchSelectorProps> = ({ entry, decision, studentO
</Text>
)}
</Text>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}></Button>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
@@ -112,30 +123,76 @@ const MatchSelector: React.FC<MatchSelectorProps> = ({ entry, decision, studentO
const createD = decision as { action: 'create'; createName: string; createPhone: string };
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Tag color="green" icon={<PlusOutlined />}></Tag>
<Input size="small" value={createD.createName} placeholder="姓名" style={{ width: 100 }}
onChange={(e) => onChange({ action: 'create', createName: e.target.value, createPhone: createD.createPhone })} />
<Input size="small" value={createD.createPhone} placeholder="手机号" style={{ width: 120 }}
onChange={(e) => onChange({ action: 'create', createName: createD.createName, createPhone: e.target.value })} />
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}></Button>
<Tag color="green" icon={<PlusOutlined />}>
</Tag>
<Input
size="small"
value={createD.createName}
placeholder="姓名"
style={{ width: 100 }}
onChange={(e) =>
onChange({
action: 'create',
createName: e.target.value,
createPhone: createD.createPhone,
})
}
/>
<Input
size="small"
value={createD.createPhone}
placeholder="手机号"
style={{ width: 120 }}
onChange={(e) =>
onChange({
action: 'create',
createName: createD.createName,
createPhone: e.target.value,
})
}
/>
<Button size="small" type="link" danger onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<Select showSearch size="small" placeholder="搜索学生…" style={{ flex: 1 }} value={undefined}
filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
<Select
showSearch
size="small"
placeholder="搜索学生…"
style={{ flex: 1 }}
value={undefined}
filterOption={(input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())
}
options={studentOptions.map((s) => ({
value: s.id,
label: `${s.name}${s.phone ? ` (${s.phone})` : ''}${s.studentNo ? ` [${s.studentNo}]` : ''}`,
}))}
onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })} />
<Button size="small" type="dashed" icon={<PlusOutlined />}
onClick={() => onChange({ action: 'create', createName: entry.name || '', createPhone: entry.phone || '' })}>
onChange={(studentId: number) => onChange({ action: 'match', matchStudentId: studentId })}
/>
<Button
size="small"
type="dashed"
icon={<PlusOutlined />}
onClick={() =>
onChange({
action: 'create',
createName: entry.name || '',
createPhone: entry.phone || '',
})
}
>
</Button>
<Button size="small" type="link" onClick={() => onChange({ action: 'skip' })}></Button>
<Button size="small" type="link" onClick={() => onChange({ action: 'skip' })}>
</Button>
</div>
);
};
@@ -151,13 +208,25 @@ interface RuleEditorProps {
onCancel: () => void;
}
const RuleEditor: React.FC<RuleEditorProps> = ({ rule, formToken, fields, onSave, onDelete, onCancel }) => {
const RuleEditor: React.FC<RuleEditorProps> = ({
rule,
formToken,
fields,
onSave,
onDelete,
onCancel,
}) => {
const [name, setName] = useState(rule?.name ?? '');
const [mappings, setMappings] = useState<Record<string, string>>(rule?.mappings ?? { name: 'field_1', phone: 'field_2' });
const [mappings, setMappings] = useState<Record<string, string>>(
rule?.mappings ?? { name: 'field_1', phone: 'field_2' },
);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) { message.warning('请输入规则名称'); return; }
if (!name.trim()) {
message.warning('请输入规则名称');
return;
}
setSaving(true);
try {
if (rule) {
@@ -170,18 +239,31 @@ const RuleEditor: React.FC<RuleEditorProps> = ({ rule, formToken, fields, onSave
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally { setSaving(false); }
} finally {
setSaving(false);
}
};
return (
<div style={{ padding: '12px 0' }}>
<Input placeholder="规则名称" value={name} onChange={(e) => setName(e.target.value)}
style={{ marginBottom: 12 }} />
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}></Text>
<Input
placeholder="规则名称"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Text>
{STUDENT_FIELDS.map((sf) => (
<div key={sf.key} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div
key={sf.key}
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}
>
<Text style={{ width: 100, textAlign: 'right', fontSize: 13 }}>{sf.label}</Text>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Select
allowClear
showSearch
@@ -193,20 +275,26 @@ const RuleEditor: React.FC<RuleEditorProps> = ({ rule, formToken, fields, onSave
value: field.key,
label: `${field.label}${field.key}`,
}))}
onChange={(value) => setMappings((prev) => {
const next = { ...prev };
if (value) next[sf.key] = value;
else delete next[sf.key];
return next;
})}
onChange={(value) =>
setMappings((prev) => {
const next = { ...prev };
if (value) next[sf.key] = value;
else delete next[sf.key];
return next;
})
}
/>
</div>
))}
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</Button>
{rule && (
<Popconfirm title="确定删除此规则?" onConfirm={() => onDelete(rule.id)}>
<Button danger icon={<DeleteOutlined />}></Button>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
<Button onClick={onCancel}></Button>
@@ -224,6 +312,8 @@ interface MatchModalProps {
}
const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplied }) => {
const { hasPermission } = usePermission();
const canTriggerSync = hasPermission('sync:trigger');
const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection');
const [loading, setLoading] = useState(false);
const [selectedRuleId, setSelectedRuleId] = useState<number | undefined>();
@@ -251,7 +341,9 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
try {
const res = await api.get<{ success: boolean; data: MatchRule[] }>('/sync/jinshuju/rules');
if (res.success) setRules(res.data);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
const handleConnectionNext = async () => {
@@ -286,7 +378,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
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 });
initial.set(entry.serialNumber, {
action: 'match',
matchStudentId: entry.suggestedStudent.id,
});
}
}
setDecisions(initial);
@@ -294,22 +389,29 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally { setLoading(false); }
} finally {
setLoading(false);
}
};
const handleApply = async () => {
if (!canTriggerSync) return;
setLoading(true);
setStep('applying');
try {
const decisionList = [...decisions.entries()].map(([serialNumber, d]) => ({ serialNumber, ...d }));
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,
);
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();
@@ -319,7 +421,9 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
const err = e as { message?: string };
if (err?.message) message.error(err.message);
setStep('match');
} finally { setLoading(false); }
} finally {
setLoading(false);
}
};
const reset = () => {
@@ -334,7 +438,10 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
credForm.resetFields();
};
const handleClose = () => { reset(); onClose(); };
const handleClose = () => {
reset();
onClose();
};
const handleScroll = (source: 'left' | 'right') => {
const el = source === 'left' ? leftRef.current : rightRef.current;
if (el) setScrollTop(el.scrollTop);
@@ -346,7 +453,8 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
}, [scrollTop]);
const getDecision = (serial: number): MatchDecision | undefined => decisions.get(serial);
const setDecision = (serial: number, d: MatchDecision) => setDecisions((prev) => new Map(prev).set(serial, d));
const setDecision = (serial: number, d: MatchDecision) =>
setDecisions((prev) => new Map(prev).set(serial, d));
const total = entries.length;
const matched = [...decisions.values()].filter((d) => d.action !== 'skip').length;
@@ -401,7 +509,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
onChange={(value) => setSelectedRuleId(value)}
options={visibleRules.map((rule) => ({ value: rule.id, label: rule.name }))}
/>
{selectedRule ? (
{canTriggerSync && selectedRule ? (
<Button
icon={<EditOutlined />}
onClick={() => {
@@ -412,15 +520,17 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
</Button>
) : null}
<Button
icon={<PlusOutlined />}
onClick={() => {
setEditingRule(null);
setShowRuleEditor(true);
}}
>
</Button>
{canTriggerSync ? (
<Button
icon={<PlusOutlined />}
onClick={() => {
setEditingRule(null);
setShowRuleEditor(true);
}}
>
</Button>
) : null}
</div>
{visibleRules.length === 0 && !showRuleEditor ? (
@@ -429,7 +539,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
</Text>
) : null}
{showRuleEditor ? (
{canTriggerSync && showRuleEditor ? (
<RuleEditor
rule={editingRule}
formToken={formToken}
@@ -459,51 +569,113 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
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} />);
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={() => setDecisions(new Map())}></Button>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text type="secondary">
{total} {matched}
</Text>
<Button size="small" onClick={() => setDecisions(new Map())}>
</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 }}>
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: LEFT_WIDTH + GAP,
height: svgHeight,
pointerEvents: 'none',
zIndex: 1,
}}
>
{lines}
</svg>
<div ref={leftRef} onScroll={() => handleScroll('left')}
style={{ width: LEFT_WIDTH, maxHeight: 480, overflowY: 'auto', flexShrink: 0 }}>
<div
ref={leftRef}
onScroll={() => handleScroll('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
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={() => handleScroll('right')}
style={{ flex: 1, maxHeight: 480, overflowY: 'auto' }}>
<div
ref={rightRef}
onScroll={() => handleScroll('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)}
<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) => setDecision(entry.serialNumber, newD)} />
onChange={(newD) => setDecision(entry.serialNumber, newD)}
/>
</div>
))}
</div>
@@ -524,40 +696,65 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
footer={
step === 'connection'
? [
<Button key="cancel" onClick={handleClose}></Button>,
<Button key="next" type="primary" onClick={handleConnectionNext}></Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
<Button key="next" type="primary" onClick={handleConnectionNext}>
</Button>,
]
: step === 'rule'
? [
<Button key="back" onClick={() => setStep('connection')}></Button>,
<Button key="cancel" onClick={handleClose}></Button>,
<Button key="next" type="primary" icon={<SearchOutlined />} loading={loading} onClick={handlePreview}>
<Button key="back" onClick={() => setStep('connection')}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
<Button
key="next"
type="primary"
icon={<SearchOutlined />}
loading={loading}
onClick={handlePreview}
>
</Button>,
]
: step === 'match'
? [
<Button key="back" onClick={() => setStep('rule')}></Button>,
<Button key="cancel" onClick={handleClose}></Button>,
<Button key="apply" type="primary" icon={<CloudUploadOutlined />} loading={loading} onClick={handleApply}>
<Button key="back" onClick={() => setStep('rule')}>
</Button>,
<Button key="cancel" onClick={handleClose}>
</Button>,
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: '确认匹配' },
]}
items={[{ title: '连接表单' }, { title: '匹配规则' }, { title: '确认匹配' }]}
/>
{step === 'connection' ? renderConnectionStep() : null}
{step === 'rule' ? renderRuleStep() : null}
{step === 'match' ? renderMatchStep() : null}
{step === 'applying' ? <Spin tip="正在同步..." style={{ display: 'block', margin: '48px auto' }} /> : null}
{step === 'applying' ? (
<Spin tip="正在同步..." style={{ display: 'block', margin: '48px auto' }} />
) : null}
</Modal>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Result, Button } from 'antd';
import { Result, Button, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
import { usePermission } from '../hooks/usePermission';
@@ -10,8 +10,11 @@ interface PermissionRouteProps {
}
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
const { permissions, hasPermission } = usePermission();
const { permissions, permissionsReady, hasPermission } = usePermission();
const navigate = useNavigate();
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
if (!hasPermission(permission)) {
let roles: string[] = [];
try {

View File

@@ -37,6 +37,8 @@ import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive';
import { message } from '../../ui/app-message';
import EditableCell from '../EditableCell';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../PermissionButton';
// ---- Types ----
@@ -318,7 +320,19 @@ const InlineArchiveSummary: React.FC<{
organizations: Array<{ id: number; name: string }>;
onRefresh: () => void;
onViewSensitive: (fieldLabel: string, value: string) => void;
}> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => {
canViewSensitive: boolean;
canChooseOrganization: boolean;
}> = ({
studentId,
student,
profile,
result,
organizations,
onRefresh,
onViewSensitive,
canViewSensitive,
canChooseOrganization,
}) => {
const saveStudent = async (field: keyof StudentInfo, value: unknown) => {
await api.put(`/students/${studentId}`, { [field]: value });
message.success('学生资料已保存');
@@ -356,9 +370,11 @@ const InlineArchiveSummary: React.FC<{
{student.phone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
<a onClick={() => onViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
@@ -402,9 +418,11 @@ const InlineArchiveSummary: React.FC<{
{student.idNumber ? (
<span>
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
@@ -438,9 +456,11 @@ const InlineArchiveSummary: React.FC<{
{student.emergencyPhone ? (
<span>
<span style={{ marginRight: 8 }}>{maskPhone(student.emergencyPhone)}</span>
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
@@ -448,15 +468,25 @@ const InlineArchiveSummary: React.FC<{
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="所属机构">
<EditableCell
value={student.organizationId}
editor="select"
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
permission="student:edit"
onSave={(next) => saveStudent('organizationId', next)}
>
{student.organization?.name ? <Tag color="purple">{student.organization.name}</Tag> : '-'}
</EditableCell>
{canChooseOrganization ? (
<EditableCell
value={student.organizationId}
editor="select"
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
permission="student:edit"
onSave={(next) => saveStudent('organizationId', next)}
>
{student.organization?.name ? (
<Tag color="purple">{student.organization.name}</Tag>
) : (
'-'
)}
</EditableCell>
) : student.organization?.name ? (
<Tag color="purple">{student.organization.name}</Tag>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="负责人">
<EditableCell
@@ -604,6 +634,7 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
studentId,
onRefresh,
}) => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -760,7 +791,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
return (
<div>
<Button
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
@@ -770,7 +802,7 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
style={{ marginBottom: 16 }}
>
</Button>
</PermissionButton>
<Table<EnrollmentRecord>
columns={columns}
dataSource={data}
@@ -783,8 +815,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
/>
<Modal
title="添加报读记录"
open={modalOpen}
onOk={handleAdd}
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
@@ -827,6 +859,7 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
const ExamScoresTab: React.FC<
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
> = ({ data, studentId, enrollments, onRefresh }) => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -995,7 +1028,8 @@ const ExamScoresTab: React.FC<
return (
<div>
<Button
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
@@ -1005,7 +1039,7 @@ const ExamScoresTab: React.FC<
style={{ marginBottom: 16 }}
>
</Button>
</PermissionButton>
<Table<ExamScoreRecord>
columns={columns}
dataSource={data}
@@ -1018,8 +1052,8 @@ const ExamScoresTab: React.FC<
/>
<Modal
title="添加考试成绩"
open={modalOpen}
onOk={handleAdd}
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
@@ -1074,6 +1108,7 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
studentId,
onRefresh,
}) => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -1183,7 +1218,8 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
return (
<div>
<Button
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
@@ -1193,7 +1229,7 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
style={{ marginBottom: 16 }}
>
</Button>
</PermissionButton>
<Table<LearningRecord>
columns={columns}
dataSource={data}
@@ -1206,8 +1242,8 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
/>
<Modal
title="添加学情记录"
open={modalOpen}
onOk={handleAdd}
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
@@ -1250,6 +1286,7 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
studentId,
onRefresh,
}) => {
const { hasPermission } = usePermission();
const [uploading, setUploading] = useState(false);
const handleDelete = async (attachmentId: number) => {
@@ -1294,11 +1331,13 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
>
</Button>
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
{hasPermission('student:edit') ? (
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
@@ -1306,37 +1345,39 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
return (
<div>
<Upload
showUploadList={false}
customRequest={async (options) => {
const formData = new FormData();
formData.append(
'file',
options.file instanceof File
? options.file
: new File([options.file as Blob], 'attachment'),
);
setUploading(true);
try {
await api.post(`/archive/${studentId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('上传成功');
options.onSuccess?.({});
onRefresh();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败';
message.error(msg);
options.onError?.(e instanceof Error ? e : new Error(msg));
} finally {
setUploading(false);
}
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
{hasPermission('student:edit') ? (
<Upload
showUploadList={false}
customRequest={async (options) => {
const formData = new FormData();
formData.append(
'file',
options.file instanceof File
? options.file
: new File([options.file as Blob], 'attachment'),
);
setUploading(true);
try {
await api.post(`/archive/${studentId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('上传成功');
options.onSuccess?.({});
onRefresh();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败';
message.error(msg);
options.onError?.(e instanceof Error ? e : new Error(msg));
} finally {
setUploading(false);
}
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
) : null}
<Table<AttachmentRecord>
columns={columns}
dataSource={data}
@@ -1359,6 +1400,10 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
inDrawer,
onClose,
}) => {
const { hasPermission, hasAnyPermission } = usePermission();
const canViewOrganizations = hasPermission('organization:view');
const canChooseOrganization =
canViewOrganizations && hasAnyPermission('student:create', 'student:edit');
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
const [loading, setLoading] = useState(false);
@@ -1381,13 +1426,17 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
}, [fetchData]);
useEffect(() => {
if (!canViewOrganizations) {
setOrganizations([]);
return;
}
api
.get('/organizations', { params: { includeArchived: 'false' } })
.then((res: unknown) => {
setOrganizations(res as Array<{ id: number; name: string }>);
})
.catch(() => {});
}, []);
}, [canViewOrganizations]);
const handlePreviewReport = useCallback(async () => {
try {
@@ -1470,7 +1519,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name}{student.studentNo ? ` (${student.studentNo})` : ''}
- {student.name}
{student.studentNo ? ` (${student.studentNo})` : ''}
</span>
</Space>
<Space>
@@ -1507,6 +1557,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
organizations={organizations}
onRefresh={fetchData}
onViewSensitive={handleViewSensitive}
canViewSensitive={hasPermission('log:create')}
canChooseOrganization={canChooseOrganization}
/>
<Tabs defaultActiveKey="enrollments" items={tabItems} />