699 lines
24 KiB
TypeScript
699 lines
24 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import {
|
||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm,
|
||
} from 'antd';
|
||
import {
|
||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||
SyncOutlined, ReloadOutlined, PlusOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { DataNode } from 'antd/es/tree';
|
||
import api from '../../api';
|
||
|
||
interface DingTalkConfig {
|
||
agentId: string;
|
||
appSecret: string;
|
||
corpId: string;
|
||
startEnable: boolean;
|
||
}
|
||
|
||
interface DingOrgTreeNodeExt {
|
||
id: number;
|
||
name: string;
|
||
parentId: number;
|
||
children: DingOrgTreeNodeExt[];
|
||
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
|
||
}
|
||
|
||
interface RoleItem {
|
||
id: number;
|
||
name: string;
|
||
status: number;
|
||
}
|
||
|
||
interface OrgTreeNodeRaw {
|
||
id: number;
|
||
name: string;
|
||
children?: OrgTreeNodeRaw[];
|
||
}
|
||
|
||
interface OrgTreeResponse {
|
||
success: boolean;
|
||
data: OrgTreeNodeRaw[];
|
||
}
|
||
|
||
interface OrgTreeWithUsersResponse {
|
||
success: boolean;
|
||
data: DingOrgTreeNodeExt[];
|
||
}
|
||
|
||
interface ImportUsersResponse {
|
||
success: boolean;
|
||
data: {
|
||
teacherCount: number;
|
||
studentCount: number;
|
||
classCount: number;
|
||
skipped: number;
|
||
warnings: string[];
|
||
};
|
||
}
|
||
|
||
interface ClassMarkForm {
|
||
deptId: number;
|
||
name: string;
|
||
code: string;
|
||
classType: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
maxStudents?: number;
|
||
notes?: string;
|
||
}
|
||
|
||
const UserTreeNode: React.FC<{
|
||
u: { userid: string; name: string; mobile: string };
|
||
isTeacher: boolean;
|
||
onToggle: () => void;
|
||
roleId: number | undefined;
|
||
defaultRoleId: number | null;
|
||
roles: Array<{ id: number; name: string }>;
|
||
onRoleChange: (roleId: number) => void;
|
||
}> = React.memo(({ u, isTeacher, onToggle, roleId, defaultRoleId, roles, onRoleChange }) => (
|
||
<Space size="small">
|
||
<Checkbox checked={isTeacher} onChange={onToggle}>老师</Checkbox>
|
||
<span style={{ fontWeight: 500 }}>{u.name}</span>
|
||
{u.mobile && <Tag style={{ marginLeft: 4 }}>{u.mobile}</Tag>}
|
||
{isTeacher && (
|
||
<Select
|
||
size="small"
|
||
style={{ width: 100, marginLeft: 8 }}
|
||
value={roleId || defaultRoleId}
|
||
onChange={onRoleChange}
|
||
options={roles.map((r) => ({ label: r.name, value: r.id }))}
|
||
placeholder="选择角色"
|
||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||
/>
|
||
)}
|
||
</Space>
|
||
));
|
||
|
||
const IntegrationConfigPage: React.FC = () => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState(false);
|
||
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
||
const [verified, setVerified] = useState<boolean | null>(null);
|
||
const [form] = Form.useForm<DingTalkConfig>();
|
||
|
||
// ── Sync Users Tab ──
|
||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [fetchingTree, setFetchingTree] = useState(false);
|
||
const [importing, setImporting] = useState(false);
|
||
const [teacherChecks, setTeacherChecks] = useState<Record<string, boolean>>({});
|
||
const [teacherRoles, setTeacherRoles] = useState<Record<string, number>>({});
|
||
const [roles, setRoles] = useState<Array<{ id: number; name: string }>>([]);
|
||
const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState<number | null>(null);
|
||
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
||
const [classMarks, setClassMarks] = useState<Record<number, ClassMarkForm>>({});
|
||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
|
||
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 () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }> }>('/integration/config');
|
||
const dt = res.data?.find((c) => c.type === 'DINGTALK');
|
||
if (dt) {
|
||
setConfig(dt.config);
|
||
setVerified(dt.verify);
|
||
form.setFieldsValue(dt.config);
|
||
}
|
||
} catch {
|
||
// not configured
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
void Promise.all([fetchConfig(), fetchRoles()]);
|
||
}, []);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
await api.post('/integration/config', { type: 'DINGTALK', config: values });
|
||
message.success('配置已保存');
|
||
await fetchConfig();
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleTest = async () => {
|
||
const values = await form.validateFields();
|
||
setTesting(true);
|
||
try {
|
||
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
|
||
type: 'DINGTALK',
|
||
config: values,
|
||
});
|
||
setVerified(res.success);
|
||
message.success(res.message);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
setVerified(false);
|
||
message.error(err?.message || '连接失败');
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
};
|
||
|
||
const fetchRoles = async () => {
|
||
try {
|
||
const res = await api.get<RoleItem[]>('/rbac/roles');
|
||
const activeRoles = res.filter((r) => r.status !== 0);
|
||
setRoles(activeRoles);
|
||
const teacherRole = activeRoles.find((r) => r.name === '班主任');
|
||
setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
|
||
} catch {
|
||
message.error('获取角色列表失败');
|
||
}
|
||
};
|
||
|
||
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 {
|
||
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
||
if (res.success && res.data) {
|
||
const toTreeNode = (nodes: OrgTreeNodeRaw[]): Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }> =>
|
||
nodes.map((n) => ({
|
||
title: n.name,
|
||
value: n.id,
|
||
children: n.children ? toTreeNode(n.children) : undefined,
|
||
}));
|
||
setDeptPickerTree(toTreeNode(res.data));
|
||
}
|
||
} catch {
|
||
message.error('获取部门架构失败');
|
||
}
|
||
};
|
||
|
||
const handleFetchOrgTree = async () => {
|
||
setFetchingTree(true);
|
||
try {
|
||
const params: Record<string, string> = {};
|
||
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
|
||
const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', { params });
|
||
if (res.success && res.data) {
|
||
setOrgTree(res.data);
|
||
setTeacherChecks({});
|
||
setTeacherRoles({});
|
||
setDrawerOpen(true);
|
||
} else {
|
||
message.error('获取组织架构失败');
|
||
}
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '获取组织架构失败');
|
||
} finally {
|
||
setFetchingTree(false);
|
||
}
|
||
};
|
||
|
||
const openClassModal = (deptId: number, deptName: string) => {
|
||
const existing = classMarks[deptId];
|
||
if (existing) {
|
||
classForm.setFieldsValue(existing);
|
||
} else {
|
||
classForm.setFieldsValue({
|
||
deptId,
|
||
name: deptName,
|
||
code: '',
|
||
classType: 'culture',
|
||
});
|
||
}
|
||
setClassModalDept({ id: deptId, name: deptName });
|
||
setClassModalOpen(true);
|
||
};
|
||
|
||
const handleClassModalOk = async () => {
|
||
const values = await classForm.validateFields();
|
||
setClassMarks((prev) => ({
|
||
...prev,
|
||
[values.deptId]: values,
|
||
}));
|
||
setClassModalOpen(false);
|
||
setClassModalDept(null);
|
||
};
|
||
|
||
const handleClassModalCancel = () => {
|
||
setClassModalOpen(false);
|
||
setClassModalDept(null);
|
||
};
|
||
|
||
const handleImportUsers = async () => {
|
||
setImporting(true);
|
||
try {
|
||
const allUsers: Array<{
|
||
dingUserId: string;
|
||
name: string;
|
||
mobile: string;
|
||
deptIds: number[];
|
||
}> = [];
|
||
|
||
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
|
||
for (const node of nodes) {
|
||
allUsers.push(
|
||
...node.users.map((u) => ({
|
||
dingUserId: u.userid,
|
||
name: u.name,
|
||
mobile: u.mobile,
|
||
deptIds: u.deptIds || [],
|
||
})),
|
||
);
|
||
flatten(node.children);
|
||
}
|
||
};
|
||
flatten(orgTree);
|
||
|
||
const payload = {
|
||
classes: Object.values(classMarks),
|
||
users: allUsers.map((u) => ({
|
||
dingUserId: u.dingUserId,
|
||
name: u.name,
|
||
mobile: u.mobile,
|
||
roleId: teacherChecks[u.dingUserId]
|
||
? (teacherRoles[u.dingUserId] || defaultTeacherRoleId)
|
||
: null,
|
||
dingDeptIds: u.deptIds,
|
||
})),
|
||
|
||
const res = await api.post('/sync/dingtalk/import-users', payload) as ImportUsersResponse;
|
||
const data = res.data;
|
||
message.success(
|
||
`导入完成:${data.teacherCount} 位老师,${data.studentCount} 位学生` +
|
||
(data.classCount ? `,${data.classCount} 个班级` : '') +
|
||
(data.skipped > 0 ? `,${data.skipped} 已跳过` : ''),
|
||
);
|
||
if (data.warnings?.length > 0) {
|
||
message.warning(data.warnings.join(';'), 8);
|
||
}
|
||
setDrawerOpen(false);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '导入失败');
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||
return nodes.map((node) => ({
|
||
title: (
|
||
<Space size="small">
|
||
<span>{node.name}</span>
|
||
{node.children.length === 0 && node.users.length > 0 && (
|
||
<>
|
||
{classMarks[node.id] ? (
|
||
<Tag
|
||
color="blue"
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => openClassModal(node.id, node.name)}
|
||
>
|
||
班级: {classMarks[node.id].name} [已标记]
|
||
</Tag>
|
||
) : (
|
||
<Button
|
||
size="small"
|
||
type="link"
|
||
icon={<span>🏫</span>}
|
||
onClick={() => openClassModal(node.id, node.name)}
|
||
>
|
||
标为班级
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</Space>
|
||
),
|
||
key: `dept-${node.id}`,
|
||
children: [
|
||
...buildTreeData(node.children),
|
||
...node.users.map((u) => ({
|
||
title: (
|
||
<UserTreeNode
|
||
key={u.userid}
|
||
u={{ userid: u.userid, name: u.name, mobile: u.mobile }}
|
||
isTeacher={!!teacherChecks[u.userid]}
|
||
onToggle={() => {
|
||
setTeacherChecks((prev) => ({
|
||
...prev,
|
||
[u.userid]: !prev[u.userid],
|
||
}));
|
||
if (teacherChecks[u.userid]) {
|
||
setTeacherRoles((prev) => {
|
||
const next = { ...prev };
|
||
delete next[u.userid];
|
||
return next;
|
||
});
|
||
}
|
||
}}
|
||
roleId={teacherRoles[u.userid]}
|
||
defaultRoleId={defaultTeacherRoleId}
|
||
roles={roles}
|
||
onRoleChange={(roleId) =>
|
||
setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId }))
|
||
}
|
||
/>
|
||
),
|
||
key: `user-${u.userid}`,
|
||
selectable: false,
|
||
})),
|
||
],
|
||
}));
|
||
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||
|
||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
|
||
|
||
const syncTabItems = config
|
||
? [
|
||
{
|
||
key: 'sync-users',
|
||
label: '同步用户',
|
||
children: (
|
||
<div>
|
||
<Alert
|
||
type="info"
|
||
message="从钉钉获取组织架构,勾选老师并分配角色,其余用户将作为学生导入。"
|
||
style={{ marginBottom: 16 }}
|
||
showIcon
|
||
/>
|
||
<Space>
|
||
<TreeSelect
|
||
treeData={deptPickerTree}
|
||
value={syncRootDeptId}
|
||
onChange={(v) => setSyncRootDeptId(v)}
|
||
placeholder="选择起始部门(不选=全部)"
|
||
allowClear
|
||
treeDefaultExpandAll
|
||
style={{ minWidth: 240 }}
|
||
onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SyncOutlined />}
|
||
loading={fetchingTree}
|
||
onClick={handleFetchOrgTree}
|
||
>
|
||
获取组织架构
|
||
</Button>
|
||
</Space>
|
||
<Modal
|
||
title={classMarks[classModalDept?.id ?? -1] ? '修改班级信息' : '标记为班级'}
|
||
open={classModalOpen}
|
||
onOk={handleClassModalOk}
|
||
onCancel={handleClassModalCancel}
|
||
destroyOnClose
|
||
>
|
||
<Form form={classForm} layout="vertical">
|
||
<Form.Item name="deptId" hidden><Input /></Form.Item>
|
||
<Form.Item name="name" label="班级名称" rules={[{ required: true, message: '请输入班级名称' }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="code" label="班级编码" rules={[{ required: true, message: '请输入班级编码' }]}>
|
||
<Input placeholder="如 CS2024-01" />
|
||
</Form.Item>
|
||
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={[
|
||
{ value: 'culture', label: '文化课' },
|
||
{ value: 'professional', label: '专业课' },
|
||
{ value: 'bootcamp', label: '集训营' },
|
||
{ value: 'sprint', label: '冲刺班' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="startDate" label="开班日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="endDate" label="结束日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="maxStudents" label="最大人数">
|
||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 表示不限制" />
|
||
</Form.Item>
|
||
<Form.Item name="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{drawerOpen && (
|
||
<Drawer
|
||
title="钉钉组织架构 — 勾选老师"
|
||
open={drawerOpen}
|
||
onClose={() => { setDrawerOpen(false); setClassMarks({}); }}
|
||
width={520}
|
||
footer={
|
||
<Space>
|
||
<Button onClick={() => { setDrawerOpen(false); setClassMarks({}); }}>取消</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<ReloadOutlined />}
|
||
loading={importing}
|
||
disabled={!defaultTeacherRoleId}
|
||
title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined}
|
||
onClick={handleImportUsers}
|
||
>
|
||
导入
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{treeData.length > 0 ? (
|
||
<Tree
|
||
treeData={treeData}
|
||
defaultExpandAll
|
||
blockNode
|
||
showLine={{ showLeafIcon: false }}
|
||
/>
|
||
) : (
|
||
<Spin />
|
||
)}
|
||
</Drawer>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
: [];
|
||
|
||
const tabItems = [
|
||
{
|
||
key: 'config',
|
||
label: '配置',
|
||
children: (
|
||
<Spin spinning={loading}>
|
||
{config && (
|
||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="启用同步">
|
||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||
{config.startEnable ? '已启用' : '未启用'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
|
||
<Alert
|
||
type="info"
|
||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||
style={{ marginBottom: 24 }}
|
||
showIcon
|
||
/>
|
||
|
||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||
<Input placeholder="dingxxxxxxxx" />
|
||
</Form.Item>
|
||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||
<Input placeholder="从钉钉开放平台获取" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="appSecret"
|
||
label="AppSecret(应用密钥)"
|
||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||
>
|
||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||
</Form.Item>
|
||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Space>
|
||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||
保存配置
|
||
</Button>
|
||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||
测试连接
|
||
</Button>
|
||
</Space>
|
||
</Form>
|
||
</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,
|
||
];
|
||
|
||
return (
|
||
<Card title="钉钉集成配置" extra={
|
||
<Space>
|
||
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||
</Space>
|
||
}>
|
||
<Tabs items={tabItems} />
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default IntegrationConfigPage;
|