feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,37 +1,26 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { integrationConfigSchema } from '../../api/schemas';
import {
Alert,
Button,
Card,
Descriptions,
Divider,
Form,
Input,
Button,
Space,
Spin,
Alert,
Descriptions,
Tag,
Divider,
Drawer,
Tree,
Select,
TreeSelect,
Modal,
DatePicker,
Row,
Col,
List,
} from 'antd';
import {
SaveOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
BankOutlined,
UserOutlined,
StopOutlined,
SaveOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
@@ -47,146 +36,78 @@ import {
commitDingTalkConfig,
readDingTalkConfigCache,
} from './integration-config-cache';
import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel';
interface DingTalkConfig {
agentId: string;
corpId: string;
}
interface DingOrgTreeNodeExt {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeExt[];
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
interface OrgTreeNodeRaw {
id: number;
name: string;
children?: OrgTreeNodeRaw[];
}
interface OrgTreeResponse {
success: boolean;
data: OrgTreeNodeRaw[];
}
interface OrgTreeWithUsersResponse {
success: boolean;
data: DingOrgTreeNodeExt[];
}
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
interface ClassItem {
id: number;
name: string;
code: string;
classType?: string;
startDate?: string;
endDate?: string;
notes?: string;
}
interface ImportResult {
imported: number;
skipped: number;
conflicts: number;
}
interface DingTalkAttendanceGroup {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
interface AttendanceGroupResponse {
success: boolean;
data: DingTalkAttendanceGroup[];
}
interface DeleteAttendanceGroupsResponse {
success: boolean;
data: {
total: number;
deleted: Array<{ groupId: number; groupName: string }>;
failed: Array<{ groupId: number; groupName: string; error: string }>;
};
}
const IntegrationConfigPage: React.FC = () => {
const initialCache = useMemo(() => readDingTalkConfigCache(), []);
const { hasPermission, hasAllPermissions } = usePermission();
const canCreateClass = hasPermission('class:create');
const [loading, setLoading] = useState(!initialCache.loaded);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<DingTalkConfig | null>(initialCache.config);
const [verified, setVerified] = useState<boolean | null>(initialCache.verified);
const [form] = Form.useForm<DingTalkConfigFormValues>();
const queryClient = useQueryClient();
// ── Manual organization sync ──
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 [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [deletingGroups, setDeletingGroups] = useState(false);
const fetchConfig = useCallback(async (showLoading = false) => {
if (showLoading) 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);
cacheDingTalkServerSnapshot(dt.config, dt.verify);
form.setFieldsValue(readDingTalkConfigCache().formValues);
} else {
setConfig(null);
setVerified(null);
cacheDingTalkServerSnapshot(null, null);
const {
data: serverConfig = { config: initialCache.config, verified: initialCache.verified },
isLoading: configLoading,
isFetching: configFetching,
} = useQuery<{ config: DingTalkConfig | null; verified: boolean | null }>({
queryKey: ['integration', 'config'],
queryFn: async () => {
try {
const res = await api.get<{
success: boolean;
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
}>('/integration/config');
const validated = validateResponse<{
success: boolean;
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
}>(integrationConfigSchema, res);
const dt = validated.data?.find((c) => c.type === 'DINGTALK');
return { config: dt?.config ?? null, verified: dt ? dt.verify : null };
} catch {
// not configured
return { config: initialCache.config, verified: initialCache.verified };
}
} catch {
// not configured
} finally {
if (showLoading) setLoading(false);
}
}, [form]);
},
});
const config = serverConfig.config;
const verified = serverConfig.verified;
const loading = !initialCache.loaded && (configLoading || configFetching);
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post('/integration/config', { type: 'DINGTALK', config: payload }),
{ invalidate: [['integration', 'config']] },
);
// 初始表单值来自本地缓存(外部存储同步)
useEffect(() => {
form.setFieldsValue(initialCache.formValues);
void fetchConfig(!initialCache.loaded);
}, [fetchConfig, form, initialCache]);
}, [form, initialCache]);
// 服务端配置同步进 localStorage 缓存,并回填表单
useEffect(() => {
cacheDingTalkServerSnapshot(config, verified);
if (config) form.setFieldsValue(readDingTalkConfigCache().formValues);
}, [config, verified, form]);
const handleSave = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setSaving(true);
try {
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
await saveMutation.mutateAsync(payload);
message.success('配置已保存');
commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId });
form.setFieldValue('appSecret', undefined);
await fetchConfig();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '保存失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -204,414 +125,23 @@ const IntegrationConfigPage: React.FC = () => {
config: payload,
},
);
setVerified(res.success);
queryClient.setQueryData(['integration', 'config'], (prev) => ({
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
verified: res.success,
}));
message.success(res.message);
} catch (e: unknown) {
const err = e as { message?: string };
setVerified(false);
queryClient.setQueryData(['integration', 'config'], (prev) => ({
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
verified: false,
}));
message.error(err?.message || '连接失败');
} finally {
setTesting(false);
}
};
const loadDeptTree = async () => {
try {
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
if (res.success && res.data) {
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
nodes.map((n) => ({
title: n.name,
value: n.id,
children: n.children ? toTreeNode(n.children) : undefined,
}));
setDeptPickerTree(toTreeNode(res.data));
}
} catch {
message.error('获取部门架构失败');
}
};
const fetchClasses = async () => {
try {
const res = await api.get<ClassItem[] | { data: ClassItem[] }>('/classes');
if (Array.isArray(res)) {
setClasses(res);
} else {
setClasses(res.data ?? []);
}
} catch {
/* ignore */
}
};
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);
setCheckedKeys([]);
setSelectedClassId(null);
setDrawerOpen(true);
fetchClasses();
} else {
message.error('获取组织架构失败');
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '获取组织架构失败');
} finally {
setFetchingTree(false);
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
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]);
const extractCheckedUsers = useCallback((): Array<{
dingUserId: string;
name: string;
mobile?: string;
}> => {
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
const walk = (nodes: DingOrgTreeNodeExt[]) => {
for (const node of nodes) {
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(orgTree);
return result;
}, [checkedKeys, orgTree]);
const handleJoinClass = async () => {
if (selectedClassId === null) return message.warning('请先选择一个班级');
const users = extractCheckedUsers();
if (users.length === 0) return message.warning('请勾选要导入的用户');
setImporting(true);
try {
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, {
users,
});
if (res.conflicts > 0) {
message.warning(
`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`,
);
} else {
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped}`);
}
setCheckedKeys([]);
setSelectedClassId(null);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const handleCreateClass = async () => {
try {
const values = await classForm.validateFields();
const users = extractCheckedUsers();
await api.post('/classes', { ...values, users });
message.success('班级创建成功');
setClassModalOpen(false);
classForm.resetFields();
setCheckedKeys([]);
fetchClasses();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '创建失败');
} finally {
setImporting(false);
}
};
const openDeleteAllGroups = async () => {
setLoadingGroups(true);
try {
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
setAttendanceGroups(response.data);
setDeleteGroupsOpen(true);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
} finally {
setLoadingGroups(false);
}
};
const deleteAllGroups = async () => {
setDeletingGroups(true);
try {
const response = await api.post<DeleteAttendanceGroupsResponse>(
'/sync/dingtalk/attendance-groups/delete-all',
);
setDeleteGroupsOpen(false);
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`已清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`已清空钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
};
const syncPanel =
config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? (
<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>
<PermissionButton
permission="sync:trigger"
danger
icon={<StopOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
{drawerOpen && (
<Drawer
title="钉钉组织架构 — 批量导入"
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
}}
width="min(900px, 100vw)"
footer={
<Space>
<Button
onClick={() => {
setDrawerOpen(false);
}}
>
</Button>
<Button
type="primary"
loading={importing}
disabled={
checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 ||
selectedClassId === null
}
onClick={handleJoinClass}
>
</Button>
{canCreateClass ? (
<Button
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
onClick={() => setClassModalOpen(true)}
>
</Button>
) : null}
</Space>
}
>
<Row gutter={[16, 16]}>
<Col xs={24} md={14}>
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
<Tree
checkable
treeData={treeData}
defaultExpandAll
showLine={{ showLeafIcon: false }}
checkedKeys={checkedKeys}
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
/>
</div>
</Col>
<Col xs={24} md={10}>
<Card
title="班级列表"
size="small"
extra={
canCreateClass ? (
<Button size="small" onClick={() => setClassModalOpen(true)}>
+
</Button>
) : null
}
>
<List
dataSource={classes}
renderItem={(cls: ClassItem) => (
<List.Item
onClick={() => setSelectedClassId(cls.id)}
style={{
cursor: 'pointer',
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
borderRadius: 4,
padding: '8px 12px',
}}
>
<List.Item.Meta
title={cls.name}
description={`${cls.code} ${cls.classType || ''}`}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{/* Create class Modal */}
{canCreateClass ? (
<Modal
title="创建班级"
open={classModalOpen}
onOk={handleCreateClass}
onCancel={() => {
setClassModalOpen(false);
classForm.resetFields();
}}
confirmLoading={importing}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<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="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
) : null}
</Drawer>
)}
<Modal
title="确认清空钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部清空"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
onOk={deleteAllGroups}
onCancel={() => setDeleteGroupsOpen(false)}
>
<Alert
type="error"
showIcon
message={`将永久清空钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会清空。清空后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List
size="small"
bordered
dataSource={attendanceGroups}
style={{ maxHeight: 280, overflow: 'auto' }}
renderItem={(group) => (
<List.Item>
<List.Item.Meta
title={group.group_name}
description={`ID ${group.group_id} · ${group.member_count}`}
/>
</List.Item>
)}
/>
</Modal>
</div>
) : null;
return (
<Card
title="钉钉集成配置"
@@ -700,10 +230,10 @@ const IntegrationConfigPage: React.FC = () => {
</Space>
</Form>
{syncPanel && (
{config && hasAllPermissions('sync:read', 'class:view', 'class:edit') && (
<>
<Divider titlePlacement="start"></Divider>
{syncPanel}
<IntegrationOrgSyncPanel canCreateClass={canCreateClass} />
</>
)}
</Spin>