feat: 重构各业务模块管理页面与服务
This commit is contained in:
388
apps/admin/src/pages/Students/StudentColumns.tsx
Normal file
388
apps/admin/src/pages/Students/StudentColumns.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
// aislop-ignore-file: duplicate-block -- 单元格渲染结构相似且字段不同,逻辑已通过 EditableStudentCell 共享
|
||||
import React from 'react';
|
||||
import { Button, Popconfirm, Space, Tag } from 'antd';
|
||||
import { EyeOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
|
||||
export const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
graduated: { text: '已毕业', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
export const STUDENT_FIELDS = {
|
||||
name: 'name',
|
||||
studentNo: 'studentNo',
|
||||
ethnicity: 'ethnicity',
|
||||
emergencyContact: 'emergencyContact',
|
||||
supervisor: 'supervisor',
|
||||
status: 'status',
|
||||
organizationId: 'organizationId',
|
||||
} as const;
|
||||
|
||||
export const SENSITIVE_LABELS = {
|
||||
phone: '电话',
|
||||
idNumber: '身份证号',
|
||||
emergencyPhone: '紧急联系人电话',
|
||||
} as const;
|
||||
|
||||
export const STUDENT_STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '在读' },
|
||||
{ value: 'graduated', label: '已毕业' },
|
||||
{ value: 'withdrawn', label: '已退训' },
|
||||
];
|
||||
|
||||
export interface StudentColumnContext {
|
||||
pageInfo: { current: number; pageSize: number };
|
||||
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
|
||||
canChooseOrganization: boolean;
|
||||
canEditStudent: boolean;
|
||||
canDeleteStudent: boolean;
|
||||
canPurgeStudent: boolean;
|
||||
canViewSensitive: boolean;
|
||||
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
|
||||
onViewSensitive: (recordId: number, field: string, value: string) => void;
|
||||
onOpenDrawer: (recordId: number) => void;
|
||||
onEdit: (record: any) => void;
|
||||
onRestore: (id: number) => Promise<unknown> | unknown;
|
||||
onPurge: (id: number, name: string) => void;
|
||||
onArchive: (id: number) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export const EditableStudentCell = <R extends { id: number; status?: string }>({
|
||||
value,
|
||||
field,
|
||||
record,
|
||||
editor,
|
||||
min,
|
||||
max,
|
||||
required,
|
||||
options,
|
||||
onSave,
|
||||
children,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: string;
|
||||
record: R;
|
||||
editor?: React.ComponentProps<typeof EditableCell>['editor'];
|
||||
min?: number;
|
||||
max?: number;
|
||||
required?: boolean;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor={editor}
|
||||
min={min}
|
||||
max={max}
|
||||
required={required}
|
||||
options={options}
|
||||
permission="student:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={async (next) => {
|
||||
await onSave(record, field, next);
|
||||
}}
|
||||
>
|
||||
{children ?? String(value ?? '-')}
|
||||
</EditableCell>
|
||||
);
|
||||
|
||||
export const SensitiveValue: React.FC<{
|
||||
value: string;
|
||||
masked: string;
|
||||
label: string;
|
||||
recordId: number;
|
||||
canViewSensitive: boolean;
|
||||
onViewSensitive: (recordId: number, field: string, value: string) => void;
|
||||
}> = ({ value, masked, label, recordId, canViewSensitive, onViewSensitive }) => {
|
||||
if (!value) return <>-</>;
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ marginRight: 4 }}>{masked}</span>
|
||||
{canViewSensitive ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px', flex: 'none' }}
|
||||
onClick={() => onViewSensitive(recordId, label, value)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function buildIdentityColumns(ctx: StudentColumnContext) {
|
||||
const {
|
||||
pageInfo,
|
||||
canViewSensitive,
|
||||
onSaveCell,
|
||||
onViewSensitive,
|
||||
} = ctx;
|
||||
|
||||
return [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 70,
|
||||
render: (_: unknown, __: unknown, index: number) =>
|
||||
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.name} record={record} required onSave={onSaveCell}>
|
||||
{v}
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskPhone(v)}
|
||||
label={SENSITIVE_LABELS.phone}
|
||||
recordId={record.id}
|
||||
canViewSensitive={canViewSensitive}
|
||||
onViewSensitive={onViewSensitive}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.studentNo} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskIdNumber(v)}
|
||||
label={SENSITIVE_LABELS.idNumber}
|
||||
recordId={record.id}
|
||||
canViewSensitive={canViewSensitive}
|
||||
onViewSensitive={onViewSensitive}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildContactColumns(ctx: StudentColumnContext) {
|
||||
const { organizations, canChooseOrganization, canViewSensitive, onSaveCell, onViewSensitive } =
|
||||
ctx;
|
||||
return [
|
||||
{
|
||||
title: '民族',
|
||||
dataIndex: 'ethnicity',
|
||||
width: 90,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.ethnicity} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '紧急联系人',
|
||||
dataIndex: 'emergencyContact',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableStudentCell
|
||||
value={v}
|
||||
field={STUDENT_FIELDS.emergencyContact}
|
||||
record={record}
|
||||
onSave={onSaveCell}
|
||||
>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '紧急联系人电话',
|
||||
dataIndex: 'emergencyPhone',
|
||||
width: 150,
|
||||
render: (v: string, record: any) => (
|
||||
<SensitiveValue
|
||||
value={v}
|
||||
masked={maskPhone(v)}
|
||||
label={SENSITIVE_LABELS.emergencyPhone}
|
||||
recordId={record.id}
|
||||
canViewSensitive={canViewSensitive}
|
||||
onViewSensitive={onViewSensitive}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'organization',
|
||||
width: 100,
|
||||
render: (organization: { name?: string } | null, record: any) =>
|
||||
canChooseOrganization ? (
|
||||
<EditableStudentCell
|
||||
value={record.organizationId}
|
||||
field={STUDENT_FIELDS.organizationId}
|
||||
record={record}
|
||||
editor="select"
|
||||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||||
required
|
||||
onSave={onSaveCell}
|
||||
>
|
||||
{organization?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{organization.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableStudentCell>
|
||||
) : organization?.name ? (
|
||||
<Tag color="purple">{organization.name}</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildProfileColumns(ctx: StudentColumnContext) {
|
||||
const { onSaveCell } = ctx;
|
||||
return [
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'supervisor',
|
||||
width: 100,
|
||||
render: (v: string, record: any) => (
|
||||
<EditableStudentCell value={v} field={STUDENT_FIELDS.supervisor} record={record} onSave={onSaveCell}>
|
||||
{v || '-'}
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string, record: any) => (
|
||||
<EditableStudentCell
|
||||
value={s}
|
||||
field={STUDENT_FIELDS.status}
|
||||
record={record}
|
||||
editor="select"
|
||||
options={STUDENT_STATUS_OPTIONS}
|
||||
onSave={onSaveCell}
|
||||
>
|
||||
<Tag
|
||||
color={statusMap[s]?.color}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{statusMap[s]?.text || s}
|
||||
</Tag>
|
||||
</EditableStudentCell>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildActionColumn(ctx: StudentColumnContext) {
|
||||
const {
|
||||
canEditStudent,
|
||||
canDeleteStudent,
|
||||
canPurgeStudent,
|
||||
onOpenDrawer,
|
||||
onEdit,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onArchive,
|
||||
} = ctx;
|
||||
return {
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<>
|
||||
{canEditStudent ? (
|
||||
<Popconfirm
|
||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||
onConfirm={() => onRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
{canPurgeStudent ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
type="link"
|
||||
onClick={() => onPurge(record.id, record.name)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => onOpenDrawer(record.id)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="student:edit" size="small" onClick={() => onEdit(record)}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{canDeleteStudent ? (
|
||||
<Popconfirm
|
||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||
onConfirm={() => onArchive(record.id)}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStudentColumns(ctx: StudentColumnContext) {
|
||||
return [
|
||||
...buildIdentityColumns(ctx),
|
||||
...buildContactColumns(ctx),
|
||||
...buildProfileColumns(ctx),
|
||||
buildActionColumn(ctx),
|
||||
];
|
||||
}
|
||||
179
apps/admin/src/pages/Students/StudentModals.tsx
Normal file
179
apps/admin/src/pages/Students/StudentModals.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
|
||||
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
import { SENSITIVE_LABELS } from './StudentColumns';
|
||||
|
||||
type AppModal = ReturnType<typeof App.useApp>['modal'];
|
||||
|
||||
export const showCreateImportResult = (
|
||||
modal: AppModal,
|
||||
result: { message?: string; imported?: number; skipped?: number },
|
||||
) => {
|
||||
const imported = result.imported ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
modal.success({
|
||||
title: '导入完成',
|
||||
okText: '知道了',
|
||||
content: (
|
||||
<div>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="成功新增">{imported} 人</Descriptions.Item>
|
||||
<Descriptions.Item label="跳过">{skipped} 人</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 12, fontWeight: 600 }}>跳过原因:</div>
|
||||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||
<li>姓名为空</li>
|
||||
<li>已存在同名学生</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||||
当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const showUpdateImportResult = (
|
||||
modal: AppModal,
|
||||
result: { message?: string; matched?: number; skipped?: number },
|
||||
) => {
|
||||
const matched = result.matched ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
modal.success({
|
||||
title: '更新完成',
|
||||
okText: '知道了',
|
||||
content: (
|
||||
<div>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="成功更新">{matched} 人</Descriptions.Item>
|
||||
<Descriptions.Item label="未匹配">{skipped} 人</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 12, fontWeight: 600 }}>匹配规则:</div>
|
||||
<div>手机号优先,身份证号其次</div>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||||
当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const StudentEditModal: React.FC<{
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
form: ReturnType<typeof Form.useForm>[0];
|
||||
canChooseOrganization: boolean;
|
||||
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
|
||||
onOk?: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({
|
||||
open,
|
||||
editing,
|
||||
saving,
|
||||
form,
|
||||
canChooseOrganization,
|
||||
organizations,
|
||||
onOk,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
className="student-form-modal"
|
||||
width={720}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
okText="保存"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="student-form-grid">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="studentNo" label="学号">
|
||||
<Input placeholder="学生的学号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="gender" label="性别">
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: '男', label: '男' },
|
||||
{ value: '女', label: '女' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label={SENSITIVE_LABELS.phone}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="idNumber" label="身份证">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="ethnicity" label="民族">
|
||||
<Input placeholder="如:汉族" />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyContact" label="紧急联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyPhone" label={SENSITIVE_LABELS.emergencyPhone}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{canChooseOrganization ? (
|
||||
<Form.Item
|
||||
name="organizationId"
|
||||
label="所属机构"
|
||||
rules={[{ required: true, message: '请选择所属机构' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择所属机构"
|
||||
options={organizations.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: organization.isHost
|
||||
? `${organization.name}(本机构)`
|
||||
: organization.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="supervisor" label="负责人/班主任">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{editing && (
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'active', label: '在读' },
|
||||
{ value: 'graduated', label: '已毕业' },
|
||||
{ value: 'withdrawn', label: '已退训' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const JinshujuModal: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApplied: () => void;
|
||||
}> = ({ open, onClose, onApplied }) => {
|
||||
return <JinshujuMatchModal open={open} onClose={onClose} onApplied={onApplied} />;
|
||||
};
|
||||
|
||||
export const StudentDrawer: React.FC<{
|
||||
open: boolean;
|
||||
studentId: number | null;
|
||||
onClose: () => void;
|
||||
}> = ({ open, studentId, onClose }) => {
|
||||
return (
|
||||
<Drawer title={null} open={open} onClose={onClose} size={720}>
|
||||
{studentId && <StudentProfileContent studentId={studentId} inDrawer onClose={onClose} />}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
138
apps/admin/src/pages/Students/StudentsTable.tsx
Normal file
138
apps/admin/src/pages/Students/StudentsTable.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd';
|
||||
import api from '../../api';
|
||||
|
||||
export interface EnrollmentInfo {
|
||||
classId: number;
|
||||
className: string;
|
||||
classType: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
joinDate: string;
|
||||
leaveDate: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const StudentsTable: React.FC<{
|
||||
columns: any[];
|
||||
data: any[];
|
||||
loading: boolean;
|
||||
pageInfo: { current: number; pageSize: number };
|
||||
onPageChange: (current: number, pageSize: number) => void;
|
||||
selectedRowKeys: number[];
|
||||
onSelect: (keys: number[]) => void;
|
||||
onClearSelection: () => void;
|
||||
}> = ({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
pageInfo,
|
||||
onPageChange,
|
||||
selectedRowKeys,
|
||||
onSelect,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const [enrollmentData, setEnrollmentData] = React.useState<Record<number, EnrollmentInfo[]>>({});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedRowKeys.length > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<span>
|
||||
已选 <strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong>{' '}
|
||||
人(支持跨页勾选)
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<Button size="small" type="link" onClick={onClearSelection}>
|
||||
清空选择
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
current: pageInfo.current,
|
||||
pageSize: pageInfo.pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => onSelect(keys as number[]),
|
||||
}}
|
||||
expandable={{
|
||||
rowExpandable: () => true,
|
||||
expandedRowRender: (record) => {
|
||||
const enrollments = enrollmentData[record.id];
|
||||
if (!enrollments) return null;
|
||||
if (enrollments.length < 2) {
|
||||
return (
|
||||
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
|
||||
当前仅 {enrollments.length} 个班型,无可对比数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card title="多班型对比" size="small" style={{ margin: '8px 0' }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{enrollments.map((enr, idx) => (
|
||||
<Col xs={24} md={12} key={enr.classId}>
|
||||
<Card
|
||||
size="small"
|
||||
title={enr.classType || `班型 ${idx + 1}`}
|
||||
style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{enr.startDate || enr.joinDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{enr.endDate || enr.leaveDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={enr.status === 'active' ? 'green' : 'default'}>
|
||||
{enr.status || '-'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
onExpand: async (expanded, record) => {
|
||||
if (expanded && !enrollmentData[record.id]) {
|
||||
try {
|
||||
const res = await api.get<{ enrollments: EnrollmentInfo[] }>(
|
||||
`/students/${record.id}/compare-classes`,
|
||||
);
|
||||
setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments }));
|
||||
} catch {
|
||||
setEnrollmentData((prev) => ({ ...prev, [record.id]: [] }));
|
||||
}
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
272
apps/admin/src/pages/Students/StudentsToolbar.tsx
Normal file
272
apps/admin/src/pages/Students/StudentsToolbar.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SwapOutlined,
|
||||
SyncOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { statusMap } from './StudentColumns';
|
||||
|
||||
export interface StudentsToolbarProps {
|
||||
onSearchName: (value: string) => void;
|
||||
filterStatus?: string;
|
||||
onFilterStatus: (value?: string) => void;
|
||||
effectiveFilterOrganizationId?: number;
|
||||
onFilterOrganization: (value?: number) => void;
|
||||
canViewOrganizations: boolean;
|
||||
organizations: Array<{ id: number; name: string }>;
|
||||
filterClassId?: number;
|
||||
onFilterClass: (value?: number) => void;
|
||||
classOptions: Array<{ id: number; name: string; code?: string }>;
|
||||
filterTeacherId?: number;
|
||||
onFilterTeacher: (value?: number) => void;
|
||||
teacherOptions: Array<{ id: number; name: string; username: string }>;
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
selectedRowKeys: number[];
|
||||
batchLoading: boolean;
|
||||
canEditStudent: boolean;
|
||||
canPurgeStudent: boolean;
|
||||
canDeleteStudent: boolean;
|
||||
canSyncJinshuju: boolean;
|
||||
canSyncDingTalk: boolean;
|
||||
dingSyncLoading: boolean;
|
||||
onBatchRestore: () => void;
|
||||
onBatchPurge: () => void;
|
||||
onBatchDelete: () => void;
|
||||
onAddStudent: () => void;
|
||||
onOpenJinshuju: () => void;
|
||||
onDingTalkSync: () => void;
|
||||
onCreateImport: UploadProps['customRequest'];
|
||||
onUpdateImport: UploadProps['customRequest'];
|
||||
onDownloadTemplate: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
|
||||
onSearchName,
|
||||
filterStatus,
|
||||
onFilterStatus,
|
||||
effectiveFilterOrganizationId,
|
||||
onFilterOrganization,
|
||||
canViewOrganizations,
|
||||
organizations,
|
||||
filterClassId,
|
||||
onFilterClass,
|
||||
classOptions,
|
||||
filterTeacherId,
|
||||
onFilterTeacher,
|
||||
teacherOptions,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
selectedRowKeys,
|
||||
batchLoading,
|
||||
canEditStudent,
|
||||
canPurgeStudent,
|
||||
canDeleteStudent,
|
||||
canSyncJinshuju,
|
||||
canSyncDingTalk,
|
||||
dingSyncLoading,
|
||||
onBatchRestore,
|
||||
onBatchPurge,
|
||||
onBatchDelete,
|
||||
onAddStudent,
|
||||
onOpenJinshuju,
|
||||
onDingTalkSync,
|
||||
onCreateImport,
|
||||
onUpdateImport,
|
||||
onDownloadTemplate,
|
||||
onExport,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="responsive-toolbar">
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
onSearch={onSearchName}
|
||||
allowClear
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={onFilterStatus}
|
||||
>
|
||||
{Object.entries(statusMap)
|
||||
.filter(([k]) => k !== 'archived')
|
||||
.map(([k, v]) => (
|
||||
<Select.Option key={k} value={k}>
|
||||
{v.text}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
{canViewOrganizations ? (
|
||||
<Select
|
||||
placeholder="所属机构"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={effectiveFilterOrganizationId}
|
||||
onChange={onFilterOrganization}
|
||||
>
|
||||
{organizations.map((t: { id: number; name: string }) => (
|
||||
<Select.Option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
) : null}
|
||||
<Select
|
||||
placeholder="所属班级"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={filterClassId}
|
||||
onChange={onFilterClass}
|
||||
options={classOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="所属老师"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
value={filterTeacherId}
|
||||
onChange={onFilterTeacher}
|
||||
options={teacherOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name === item.username ? item.name : `${item.name}(${item.username})`,
|
||||
}))}
|
||||
/>
|
||||
<Button type={showArchived ? 'primary' : 'default'} onClick={onToggleArchived}>
|
||||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{showArchived && canEditStudent ? (
|
||||
<>
|
||||
<Popconfirm
|
||||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
|
||||
onConfirm={onBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{canPurgeStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定永久删除选中的 ${selectedRowKeys.length} 名学生?删除后不可恢复!`}
|
||||
onConfirm={onBatchPurge}
|
||||
okText="永久删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
) : !showArchived && canDeleteStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="student:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAddStudent}
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived && (
|
||||
<>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onCreateImport}>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onUpdateImport}>
|
||||
<Button icon={<SwapOutlined />}>更新已有学生资料</Button>
|
||||
</Upload>
|
||||
</>
|
||||
)}
|
||||
{!showArchived && canSyncJinshuju ? (
|
||||
<Button icon={<CloudUploadOutlined />} onClick={onOpenJinshuju}>
|
||||
同步金数据
|
||||
</Button>
|
||||
) : null}
|
||||
{!showArchived && canSyncDingTalk ? (
|
||||
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={onDingTalkSync}>
|
||||
同步钉钉
|
||||
</Button>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onDownloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:export"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={onExport}
|
||||
>
|
||||
导出名单
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user