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);

View File

@@ -71,7 +71,7 @@ interface ScheduleSyncResult {
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
groups: Array<{ className: string; groupId: number; itemCount: number }>;
}
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
@@ -112,12 +112,12 @@ const SchedulesPage: React.FC = () => {
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
activeSchedules: number; mappedTeachers: number; totalTeachers: number;
activeSchedules: number; mappedClasses: number; totalClasses: number;
} | null>(null);
const [syncResult, setSyncResult] = useState<{
scheduleCount: number; shiftCount: number; groupCount: number;
syncedItems: number; skippedNoMapping: number;
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30);
@@ -128,7 +128,7 @@ const SchedulesPage: React.FC = () => {
setSyncResult(null);
try {
const res = await api.get<{
success: boolean; data: { activeSchedules: number; mappedTeachers: number; totalTeachers: number };
success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
}>('/sync/schedule/status');
setSyncStatus(res.data);
} catch {
@@ -986,17 +986,17 @@ const SchedulesPage: React.FC = () => {
{syncResult.skippedNoMapping > 0 && (
<Alert
type="warning"
message={`${syncResult.skippedNoMapping} 条排课因教师未绑定钉钉而跳过`}
message={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
{syncResult.groups.map((g) => (
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
{g.deptName}{g.itemCount}
{g.className}{g.itemCount}
</Tag>
))}
</div>
@@ -1011,20 +1011,20 @@ const SchedulesPage: React.FC = () => {
</Col>
<Col span={8}>
<Statistic
title="已绑定教师"
value={syncStatus.mappedTeachers}
suffix={`/ ${syncStatus.totalTeachers}`}
valueStyle={{ color: syncStatus.mappedTeachers < syncStatus.totalTeachers ? '#faad14' : '#3f8600' }}
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }}
/>
</Col>
<Col span={8}>
<Statistic title="绑定" value={syncStatus.totalTeachers - syncStatus.mappedTeachers} suffix="" />
<Statistic title="绑定学生班级" value={syncStatus.totalClasses - syncStatus.mappedClasses} suffix="" />
</Col>
</Row>
{syncStatus.mappedTeachers < syncStatus.totalTeachers && (
{syncStatus.mappedClasses < syncStatus.totalClasses && (
<Alert
type="warning"
message={`${syncStatus.totalTeachers - syncStatus.mappedTeachers} 位教师未绑定钉钉,其排课将被跳过。请先执行组织架构同步`}
message={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生`}
style={{ marginBottom: 16 }}
showIcon
/>