fix(admin): only show class-mark button on leaf departments with users
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||||
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||||
SyncOutlined, ReloadOutlined,
|
SyncOutlined, ReloadOutlined, PlusOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
@@ -49,13 +49,11 @@ interface OrgTreeWithUsersResponse {
|
|||||||
|
|
||||||
interface ImportUsersResponse {
|
interface ImportUsersResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: {
|
teacherCount: number;
|
||||||
teacherCount: number;
|
studentCount: number;
|
||||||
studentCount: number;
|
classCount: number;
|
||||||
classCount: number;
|
skipped: number;
|
||||||
skipped: number;
|
warnings: string[];
|
||||||
warnings: string[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClassMarkForm {
|
interface ClassMarkForm {
|
||||||
@@ -120,6 +118,13 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
|
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
|
||||||
const [classForm] = Form.useForm<ClassMarkForm>();
|
const [classForm] = Form.useForm<ClassMarkForm>();
|
||||||
|
|
||||||
|
// ── 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 () => {
|
const fetchConfig = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -187,6 +192,55 @@ 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 () => {
|
const loadDeptTree = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
||||||
@@ -294,9 +348,9 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
dingDeptIds: u.deptIds,
|
dingDeptIds: u.deptIds,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
console.log('[import payload]', JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
const res = await api.post<ImportUsersResponse>('/sync/dingtalk/import-users', payload);
|
const data = await api.post<ImportUsersResponse>('/sync/dingtalk/import-users', payload);
|
||||||
const data = res.data;
|
|
||||||
message.success(
|
message.success(
|
||||||
`导入完成:${data.teacherCount} 位老师,${data.studentCount} 位学生` +
|
`导入完成:${data.teacherCount} 位老师,${data.studentCount} 位学生` +
|
||||||
(data.classCount ? `,${data.classCount} 个班级` : '') +
|
(data.classCount ? `,${data.classCount} 个班级` : '') +
|
||||||
@@ -319,23 +373,27 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
title: (
|
title: (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
{classMarks[node.id] ? (
|
{node.children.length === 0 && node.users.length > 0 && (
|
||||||
<Tag
|
<>
|
||||||
color="blue"
|
{classMarks[node.id] ? (
|
||||||
style={{ cursor: 'pointer' }}
|
<Tag
|
||||||
onClick={() => openClassModal(node.id, node.name)}
|
color="blue"
|
||||||
>
|
style={{ cursor: 'pointer' }}
|
||||||
班级: {classMarks[node.id].name} [已标记]
|
onClick={() => openClassModal(node.id, node.name)}
|
||||||
</Tag>
|
>
|
||||||
) : (
|
班级: {classMarks[node.id].name} [已标记]
|
||||||
<Button
|
</Tag>
|
||||||
size="small"
|
) : (
|
||||||
type="link"
|
<Button
|
||||||
icon={<span>🏫</span>}
|
size="small"
|
||||||
onClick={() => openClassModal(node.id, node.name)}
|
type="link"
|
||||||
>
|
icon={<span>🏫</span>}
|
||||||
标为班级
|
onClick={() => openClassModal(node.id, node.name)}
|
||||||
</Button>
|
>
|
||||||
|
标为班级
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -546,6 +604,81 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
</Spin>
|
</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,
|
...syncTabItems,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user