feat(sync): sync class schedules to DingTalk students

Group active schedules by class, resolve enrolled students through DingTalk mappings, reuse shifts and attendance groups, and expose class-based sync status in the admin UI.
This commit is contained in:
2026-07-10 14:11:39 +08:00
parent bc6b8b0095
commit 55881863c1
5 changed files with 246 additions and 192 deletions

View File

@@ -6,9 +6,10 @@ import {
} from 'antd';
import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, PlusOutlined, BankOutlined, UserOutlined,
SyncOutlined, BankOutlined, UserOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
import api from '../../api';
interface DingTalkConfig {
@@ -42,6 +43,8 @@ interface OrgTreeWithUsersResponse {
data: DingOrgTreeNodeExt[];
}
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
interface ClassItem {
id: number;
name: string;
@@ -72,7 +75,7 @@ const IntegrationConfigPage: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = useState(false);
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
const [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<ClassItem[]>([]);
@@ -140,7 +143,7 @@ const IntegrationConfigPage: React.FC = () => {
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[] }> }> =>
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
nodes.map((n) => ({
title: n.name,
value: n.id,
@@ -188,23 +191,36 @@ const IntegrationConfigPage: React.FC = () => {
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => ({
title: (
<Space size="small">
<BankOutlined />
<span>{node.name}</span>
<Tag>{node.users.length}</Tag>
</Space>
),
key: `dept-${node.id}`,
children: [
...buildTreeData(node.children),
...node.users.map((u) => ({
title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
return nodes.map((node) => {
const users = node.users ?? [];
const children: DataNode[] = [
...buildTreeData(node.children ?? []),
...users.map((u) => ({
title: (
<Space>
<UserOutlined />
<span>{u.name}</span>
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
</Space>
),
key: `user-${u.userid}`,
isLeaf: true,
})),
],
}));
];
return {
title: (
<Space size="small">
<BankOutlined />
<span>{node.name}</span>
<Tag>{users.length}</Tag>
</Space>
),
key: `dept-${node.id}`,
// Only attach children when there are any, so empty/leaf departments
// don't render a phantom expand arrow that opens to nothing.
...(children.length > 0 ? { children } : {}),
};
});
}, []);
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
@@ -213,12 +229,12 @@ const IntegrationConfigPage: React.FC = () => {
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
const walk = (nodes: DingOrgTreeNodeExt[]) => {
for (const node of nodes) {
for (const u of node.users) {
for (const u of node.users ?? []) {
if (checkedKeys.includes(`user-${u.userid}`)) {
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
}
}
walk(node.children);
walk(node.children ?? []);
}
};
walk(orgTree);