forked from wangziqi/gongxue-base
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user