fix: resolve 5 review findings

Critical 1: Remove broken '钉钉绑定' tab (IntegConfig) — deleted /rbac/user-ding-mappings calls
Critical 2: Fix decorator placement in CreateClassDto — @IsOptional @IsArray restored to teachers
Important 1: Restore sync.service.spec.ts from 1fa3363, strip importDingTalkUsers tests
Important 2: Fix N+1 in batchImportStudents — batched find/save with In() operator
Important 3: Add migrate-student-ding-mapping.sql
This commit is contained in:
2026-07-09 17:25:44 +08:00
parent 1f63a53222
commit 9b7a8e37b4
6 changed files with 77 additions and 172 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm,
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Row, Col, List,
} from 'antd';
import {
@@ -79,12 +79,6 @@ const IntegrationConfigPage: React.FC = () => {
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
// ── DingTalk Bindings Tab ──
const [bindings, setBindings] = useState<Array<{ id: number; userId: number; dingUserId: string; dingName: string | null; dingMobile: string | null; user: { id: number; username: string; name: string } }>>([]);
const [unboundUsers, setUnboundUsers] = useState<Array<{ id: number; username: string; name: string }>>([]);
const [bindModalOpen, setBindModalOpen] = useState(false);
const [bindForm] = Form.useForm<{ userId: number; dingUserId: string; dingName?: string; dingMobile?: string }>();
const [bindSubmitting, setBindSubmitting] = useState(false);
const fetchConfig = async () => {
setLoading(true);
@@ -141,54 +135,6 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const fetchBindings = useCallback(async () => {
try {
const res = await api.get<typeof bindings>('/rbac/user-ding-mappings');
setBindings(res);
} catch { /* ignore */ }
}, []);
const fetchUnboundUsers = useCallback(async () => {
try {
const res = await api.get<typeof unboundUsers>('/rbac/user-ding-mappings/unbound-users');
setUnboundUsers(res);
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchBindings();
}, [fetchBindings]);
const handleBindSubmit = async () => {
try {
const values = await bindForm.validateFields();
setBindSubmitting(true);
await api.post('/rbac/user-ding-mappings', values);
message.success('绑定成功');
setBindModalOpen(false);
bindForm.resetFields();
fetchBindings();
} catch (e: unknown) {
const err = e as { message?: string };
if (err.message) message.error(err.message);
} finally {
setBindSubmitting(false);
}
};
const handleUnbind = async (id: number) => {
Modal.confirm({
title: '确认解绑?',
content: '解绑后该用户将无法自动匹配钉钉考勤。',
okText: '解绑',
okType: 'danger',
onOk: async () => {
await api.delete(`/rbac/user-ding-mappings/${id}`);
message.success('已解绑');
fetchBindings();
},
});
};
const loadDeptTree = async () => {
try {
@@ -503,81 +449,6 @@ const IntegrationConfigPage: React.FC = () => {
</Spin>
),
},
{
key: 'bindings',
label: '钉钉绑定',
children: (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}></span>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={async () => {
await fetchUnboundUsers();
setBindModalOpen(true);
}}
>
</Button>
</div>
<Table
dataSource={bindings}
rowKey="id"
columns={[
{
title: '本地用户',
key: 'user',
render: (_: unknown, r: typeof bindings[number]) => (
<Space>
<span>{r.user?.name || '-'}</span>
<Tag>{r.user?.username}</Tag>
</Space>
),
},
{ title: '钉钉用户ID', dataIndex: 'dingUserId', key: 'dingUserId' },
{ title: '钉钉名称', dataIndex: 'dingName', key: 'dingName', render: (v: string | null) => v || '-' },
{ title: '钉钉手机', dataIndex: 'dingMobile', key: 'dingMobile', render: (v: string | null) => v || '-' },
{
title: '操作',
key: 'actions',
render: (_: unknown, r: typeof bindings[number]) => (
<Popconfirm title="确认解绑?" onConfirm={() => handleUnbind(r.id)} okText="解绑" okType="danger">
<Button size="small" danger></Button>
</Popconfirm>
),
},
]}
pagination={{ pageSize: 20 }}
locale={{ emptyText: '暂无绑定记录' }}
/>
<Modal
title="新增钉钉绑定"
open={bindModalOpen}
onOk={handleBindSubmit}
onCancel={() => { setBindModalOpen(false); bindForm.resetFields(); }}
confirmLoading={bindSubmitting}
>
<Form form={bindForm} layout="vertical">
<Form.Item name="userId" label="本地用户" rules={[{ required: true, message: '请选择用户' }]}>
<Select
showSearch
placeholder="搜索本地用户"
optionFilterProp="label"
options={unboundUsers.map((u) => ({
value: u.id,
label: `${u.name || u.username} (${u.username})`,
}))}
/>
</Form.Item>
<Form.Item name="dingUserId" label="钉钉用户ID" rules={[{ required: true, message: '请输入钉钉用户ID' }]} extra="在钉钉管理后台 → 通讯录 → 成员详情 中可查看">
<Input placeholder="如: manager123" />
</Form.Item>
</Form>
</Modal>
</div>
),
},
...syncTabItems,
];