Compare commits

...

32 Commits

Author SHA1 Message Date
db0d972633 Merge pull request 'fix: 上传与路由校验加固、甘特图时间线修复' (#63) from fix/quick-wins-and-gantt into main 2026-08-07 08:38:45 +00:00
8d4ebcf9c0 fix(admin,server): 修复 Dashboard 甘特图时间线与数据校验
Some checks failed
CI / check (pull_request) Failing after 1m54s
2026-08-07 16:38:16 +08:00
5f9566e26d fix(server): 修正档案成绩趋势图 y 轴坐标字符串拼接 2026-08-07 16:38:09 +08:00
5b5ffb5b9e fix(server): 路由 id 使用 ParseIntPipe,限制分页与批量数组上限 2026-08-07 16:38:03 +08:00
899d2dde5b fix(admin,server): 上传改用 file.buffer 并移除手写 multipart 头 2026-08-07 16:37:55 +08:00
54b002455f chore(admin): 声明 react-syntax-highlighter 运行时依赖 2026-08-07 16:37:47 +08:00
b032890b4f style(admin): 清理考勤页与排课表的冗余装饰与标签 2026-08-07 09:50:52 +08:00
f560b046a4 Merge pull request 'fix(admin): 账号管理用户名过长溢出' (#62) from fix/users-username-overflow into main 2026-08-06 09:11:44 +00:00
549a3ff14b fix(admin): 账号管理用户名过长溢出
Some checks failed
CI / check (pull_request) Failing after 1m48s
2026-08-06 17:10:06 +08:00
7fdfdbc717 Merge pull request 'fix(admin): 钱包列表隐藏学号显示' (#61) from fix/wallet-hide-student-no into main 2026-08-06 09:07:50 +00:00
edb798b752 fix(admin): 钱包列表隐藏学号显示
Some checks failed
CI / check (pull_request) Failing after 3m30s
2026-08-06 17:06:24 +08:00
a436d9aa38 Merge branch 'feat/ai-a2ui-context' into main 2026-08-06 08:27:24 +00:00
bbeea440f9 fix(admin): dashboard 待办卡片点击导航去重与防抖
Some checks failed
CI / check (pull_request) Failing after 3m19s
- 同一目标路径不再重复 navigate,避免历史记录堆叠
- 400ms 冷却期内忽略重复/幽灵点击,防止来回跳转
- 新增 shouldNavigateTodoCard 守卫单元测试
2026-08-06 16:11:13 +08:00
9048816abc feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件
- 删除 imports.preflight 解析器与 PreflightReport 类型
- SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard
- 前端同步移除预检类型/组件/测试,保留导入向导
2026-08-06 15:50:38 +08:00
14db28afc6 fix(ai): 修复 Nest DI 启动失败
- A2uiSubmissionsService/DataSource/BusinessContextService 等构造参数
  由 import type 改为值导入,确保 Nest 能解析依赖
- 验证:dev 模式后端正常启动,相关 243 个测试通过
2026-08-06 15:39:33 +08:00
c72ff2cb8a fix(ai): 清理 aislop 扫描告警
- 拆分 SSE reducer 到 sseReducer.ts,provider.ts 降至 220 行
- artifact 合并工具抽到 uiArtifacts.ts,去除双重类型断言
- 业务上下文注册表改用 field/relation/entity 构建器收敛重复
- aislop 分数 71 → 81(Healthy),AI Slop 0 告警
2026-08-06 15:35:12 +08:00
24e0ecbdaf feat(ai): 业务上下文感知与 A2UI 链路统一
- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流)
- 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具
- SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行
- A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件
- 提交回灌携带 submissionId / fieldErrors / 下一步建议
- 前端 uiArtifacts 归一化与过期表单禁用
2026-08-06 15:23:23 +08:00
d9c541dacc fix(admin): 跨标签页权限写入不再整页刷新
storage 事件里 permissions 变更改为原地写入权限 store,
仅 gongxue-auth token 实际变化(登录/退出/切换账号)才 reload,
避免多标签页相互触发刷新风暴
2026-08-06 14:53:22 +08:00
ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00
259271f56c fix(admin): Alert message 弃用 API 全部迁移为 title 2026-08-06 12:20:24 +08:00
824c33a71c refactor(ai-chat): 拆分超大文件并通过 aislop 100 分门槛
- AiChatService 拆出抽象基类,职责不变
- submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容
- 导入工具执行器抽取共享 runner,消除重复签名块
- 全仓 aislop 扫描 100/100,0 警告
2026-08-06 12:13:37 +08:00
7f09d5271e feat(imports): 导入向导兼容旧版工作簿并完善执行/预览
- 新增 xls 工作簿回退解析与测试
- 执行/预览/权限校验逻辑完善
2026-08-06 11:59:51 +08:00
7f3e30ba38 fix(archive): 档案聚合接口 null 字段归一化为空串
避免可选列缺省返回 null 导致前端响应校验报「字段格式异常」
2026-08-06 11:59:43 +08:00
d0ab8da01b feat(admin): AI 预检卡片交互与消息组件完善
- 预检卡支持列映射、导入策略确认与生成导入向导
- 消息组件/API 契约配套更新,含集成测试
2026-08-06 11:59:36 +08:00
6a18fd264d feat(ai-chat): AI 导入确认流程完善并清理代码质量
- 新增导入确认/映射解析辅助,支持预检后生成导入向导
- 抽取 parseAttachmentArgs/beginImportToolRun 等重复逻辑
- 双重类型断言改为运行时守卫,消除 aislop 告警
2026-08-06 11:59:28 +08:00
026a9f35d8 fix(admin): 迁移 antd v6 弃用 API 并清理控制台警告
- Card bordered -> variant;Modal maskClosable -> mask.closable
- InputNumber addonBefore/After -> prefix/suffix;Statistic valueStyle -> styles.content
- Alert message -> title;Steps items.description -> items.content
- 移除考勤页未挂载 Form 上的 setFieldsValue,消除 useForm 警告
2026-08-06 11:58:58 +08:00
2e7bb81ebd fix(admin): 修正接口 schema 与路由不匹配
- classroomSchema 允许 building/status 为 null,教室查看页恢复数据
- 排期 matrix 单元格为对象而非数组,教室排期页恢复渲染
- 押金批量收取改用 /deposits/eligible-students 路由
- 学生下拉 schema 按 /deposits/student-lookups 实际结构校验
- 水电费录入改走 /expenses/student-utility,批量恢复改为 PUT
2026-08-06 11:58:19 +08:00
67e14e357c fix(imports): 导入向导 sheets_json 扩容为 MEDIUMTEXT
start_import_wizard 存整表行数据,TEXT(64KB) 在 430 行文件上触发
Data too long for column 'sheets_json',扩容至 MEDIUMTEXT(16MB) 并补迁移
(实际表名 import_runs,原迁移误写 import_run 导致空跑)
2026-08-05 22:26:14 +08:00
6249fefc64 refactor(ai-chat): 移除 excel_analyze/office_analyze 工具与 OfficeKit 依赖
- 删除 excel_analyze/office_analyze 两个 A2UI 工具(schema、分发、执行器、测试)
- 附件分析改为依赖上传时自动提取的文本与 preflight_import 报告 errorSamples
- 移除 @officecli/officecli 依赖及 OfficeCliService(PPT 上传不再自动提取文本)
- 保留 AiExcelReaderService(附件文本提取与导入预检仍使用)
2026-08-05 22:26:05 +08:00
d1c933f032 refactor(server): 清理无用导入与类型转换
- 移除测试/控制器中未使用的导入与实例
- bills/classroom-rentals 改用 ESM 导入替代 require
- bills.service 去掉多余断言、backfill 显式转字符串、main.ts void bootstrap
2026-08-05 21:14:17 +08:00
1a1e90c72f fix(admin): 补全 React hooks 依赖并修复页面稳定性
- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期
- ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验
- 学生同步结果改用 recordsCount 展示
2026-08-05 21:14:08 +08:00
ab4765cee5 feat: Excel 导入预检与动态问答,AI 聊天/文件解析体验修复
- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志
- ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志
- admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
2026-08-05 21:12:50 +08:00
172 changed files with 6128 additions and 2964 deletions

View File

@@ -33,6 +33,7 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router": "^8.3.0",
"react-syntax-highlighter": "^16.1.1",
"use-immer": "^0.11.0",
"usehooks-ts": "^3.1.1",
"zod": "^4.4.3",

View File

@@ -31,9 +31,7 @@ export async function createImportRun(
if (options.mapping && Object.keys(options.mapping).length > 0) {
form.append('mapping', JSON.stringify(options.mapping));
}
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form);
return res.data;
}

View File

@@ -153,8 +153,8 @@ export const classroomSchema = z
.object({
id: z.number(),
name: z.string(),
building: z.string().optional(),
status: z.string().optional(),
building: z.string().nullable().optional(),
status: z.string().nullable().optional(),
})
.passthrough();
@@ -165,7 +165,7 @@ export const studentSchema = z
.object({
id: z.number(),
name: z.string(),
studentNo: z.string().optional(),
studentNo: z.string().nullable().optional(),
status: z.string(),
})
.passthrough();
@@ -180,7 +180,11 @@ export const depositSchema = z
export const depositsSchema = z.array(depositSchema);
export const depositStudentLookupSchema = z
.object({ studentId: z.number(), name: z.string().optional() })
.object({
id: z.number(),
name: z.string().nullable().optional(),
studentNo: z.string().nullable().optional(),
})
.passthrough();
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
@@ -217,6 +221,18 @@ export const expenseRecordSchema = z
export const expenseRecordsSchema = z.array(expenseRecordSchema);
export const expenseStudentLookupSchema = z
.object({
id: z.number(),
name: z.string().nullable().optional(),
studentNo: z.string().nullable().optional(),
})
.passthrough();
export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema);
export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown()));
export const expenseLookupsSchema = z
.object({
rooms: z.array(z.record(z.string(), z.unknown())),

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { ganttRoomsSchema } from './dashboard';
describe('ganttRoomsSchema 接口校验', () => {
const validPayload = [
{
roomNumber: 'A101',
occupancies: [
{
studentName: '张三',
studentId: 3,
checkInDate: '2026-05-01',
checkOutDate: null,
billingStartDate: '2026-05-01',
billingEndDate: null,
},
],
},
];
it('接受合法的甘特图数据studentId 为数字)', () => {
expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true);
});
it('拒绝缺少 checkInDate 的入住记录', () => {
const payload = [
{
roomNumber: 'A101',
occupancies: [{ studentName: '张三', checkOutDate: null }],
},
];
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
});
it('拒绝缺少 studentName 的入住记录', () => {
const payload = [
{
roomNumber: 'A101',
occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }],
},
];
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
});
});

View File

@@ -4,7 +4,8 @@ export const classroomScheduleSchema = z
.object({
classrooms: z.array(z.record(z.string(), z.unknown())),
organizations: z.array(z.record(z.string(), z.unknown())),
matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))),
// matrix: 教室 id → 日期 → 单条排课/租赁记录(对象,非数组)
matrix: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.unknown()))),
summary: z.record(z.string(), z.record(z.string(), z.unknown())),
days: z.number().optional(),
})
@@ -58,8 +59,19 @@ export const classAttendanceRankingSchema = z
export const ganttRoomsSchema = z.array(
z
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
.passthrough(),
.object({
roomNumber: z.string(),
occupancies: z.array(
z.object({
studentName: z.string(),
studentId: z.union([z.string(), z.number()]).optional(),
checkInDate: z.string(),
checkOutDate: z.string().nullable(),
billingStartDate: z.string().optional(),
billingEndDate: z.string().nullable().optional(),
}),
),
}),
);
export const classroomOccupanciesSchema = z.array(

View File

@@ -34,6 +34,18 @@ export const importStepDetailSchema = z
})
.passthrough();
export const importRunSettingsSchema = z
.object({
mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(),
organization: z.string().nullable().optional(),
updateExisting: z.boolean().optional(),
duplicatePolicy: z.enum(['error', 'skip']).optional(),
skipUnmatched: z.boolean().optional(),
})
.passthrough()
.nullable()
.optional();
export const importRunDetailSchema = z
.object({
id: z.string(),
@@ -44,6 +56,7 @@ export const importRunDetailSchema = z
createdAt: z.string(),
sheets: z.array(importSheetMetaSchema),
steps: z.array(importStepDetailSchema),
settings: importRunSettingsSchema,
})
.passthrough();

View File

@@ -54,7 +54,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [loadingList, setLoadingList] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
// 断点首帧可能尚未解析isMobile 误判为 true桌面端默认展开会话侧边栏
// 移动端通过 effectiveSidebarOpen 统一隐藏。
const [sidebarOpen, setSidebarOpen] = useState(true);
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
const [skills, setSkills] = useState<AiSkill[]>([]);
const [conversationStatus, setConversationStatus] = useState<
@@ -228,7 +230,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
},
});
},
[setConversation],
[setConversation, modal],
);
/** 删除单个会话时中止请求并清理会话运行时状态 */
@@ -269,13 +271,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
},
});
},
[
activeId,
conversations,
switchConversation,
removeConversation,
removeConversationEntry,
],
[
activeId,
conversations,
switchConversation,
removeConversation,
removeConversationEntry,
modal,
],
);
const enterSelectionMode = useCallback(() => {
@@ -358,11 +361,12 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
activeId,
conversations,
removeConversation,
removeConversationEntry,
selectedKeys,
switchConversation,
setConversations,
]);
removeConversationEntry,
selectedKeys,
switchConversation,
setConversations,
modal,
]);
const conversationMenu = useCallback(
(item: ConversationItemType): MenuProps => ({

View File

@@ -52,6 +52,9 @@ const toolLabels: Record<string, string> = {
search_classrooms: '查询教室',
search_classroom_rentals: '查询教室租用',
get_sync_status: '查询同步状态',
get_business_context: '读取业务流程',
get_entity_schema: '读取实体字典',
get_pending_tasks: '查询业务待办',
};
const markdownComponents = {

View File

@@ -84,6 +84,7 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
);
if (!form) return null;
const finished = Boolean(form.submitted) || form.status === 'submitted';
const expired = form.status === 'expired';
const handleFinish = (values: Record<string, unknown>) => {
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
@@ -97,8 +98,10 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
{form.description}
</Typography.Text>
)}
{finished ? (
<Alert type="success" showIcon message="已提交AI 正在处理…" />
{expired ? (
<Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
) : finished ? (
<Alert type="success" showIcon title="已提交AI 正在处理…" />
) : (
<Form
layout="vertical"

View File

@@ -183,7 +183,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
<Alert
type="warning"
showIcon
message="此导入预览已被新的预览替代,已失效"
title="此导入预览已被新的预览替代,已失效"
description="如需导入,请使用最新的预览卡。"
/>
)}
@@ -341,7 +341,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
/>
)}
{activeStatus === 'failed' && (
<Alert type="error" showIcon message="本步导入失败,可重试" />
<Alert type="error" showIcon title="本步导入失败,可重试" />
)}
{activeSection.resultSummary && activeStatus === 'submitted' && (
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
@@ -377,7 +377,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
</Popconfirm>
)}
</Flex>
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
{submitted && <Alert type="success" showIcon title="已确认导入,数据已入库" />}
{review.error && (
<Alert
type="error"

View File

@@ -35,7 +35,6 @@ export const aiChatApi = {
form.append('file', file);
return (
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
})
).data;

View File

@@ -102,6 +102,38 @@ describe('AI chat bubble rendering', () => {
expect(container.textContent).toContain('已提交');
});
it('renders an expired A2UI form as disabled without submit action', async () => {
let submitted = false;
const el = document.createElement('div');
container = el;
document.body.appendChild(el);
root = createRoot(el);
await act(async () => {
root?.render(
<DynamicForm
form={{
id: 'form-expired',
title: '已失效的表单',
status: 'expired',
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
}}
onSubmit={() => {
submitted = true;
}}
/>,
);
});
expect(el.textContent).toContain('已失效');
expect(el.querySelector('button[type="submit"]')).toBeNull();
await act(async () => {
const buttons = Array.from(el.querySelectorAll('button'));
buttons.forEach((button) => button.click());
});
expect(submitted).toBe(false);
});
it('renders an A2UI review card and submits via the confirm button', async () => {
let submittedId: string | null = null;
const review: AiReviewSchema = {

View File

@@ -78,6 +78,52 @@ describe('AI chat history mapper', () => {
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
});
it('restores uiArtifacts from message metadata and derives legacy lists', () => {
const mapped = mapHistoryMessage({
id: 8,
role: 'assistant',
content: '请处理',
reasoningContent: null,
status: 'completed',
errorCode: null,
createdAt: '2026-07-23T00:00:00.000Z',
metadata: {
uiArtifacts: [
{
id: 'form-10',
type: 'form',
status: 'submitted',
messageId: 8,
conversationId: 3,
payload: {
id: 'form-10',
title: '新增学生',
status: 'submitted',
fields: [],
},
},
{
id: 'review-10',
type: 'review',
status: 'expired',
messageId: 8,
conversationId: 3,
payload: {
id: 'review-10',
title: '旧预览',
status: 'expired',
sections: [],
},
},
],
},
});
expect(mapped.message.uiArtifacts).toHaveLength(2);
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' });
expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' });
});
it('restores a persisted A2UI review from message metadata', () => {
const mapped = mapHistoryMessage({
id: 6,
@@ -139,4 +185,5 @@ describe('AI chat history mapper', () => {
expect(mapped.message.charts).toHaveLength(1);
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
});
});

View File

@@ -3,11 +3,13 @@ import type {
AiChatMessage,
AiChatMessageStatus,
AiChartSchema,
AiArtifactSchema,
AiFormSchema,
AiMessageRecord,
AiReviewSchema,
AiToolRun,
} from './types';
import { mergeArtifactIntoMessage } from './uiArtifacts';
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
if (record.status === 'pending') return 'loading';
@@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
return [a2uiChart as AiChartSchema];
}
function historyArtifacts(record: AiMessageRecord) {
const artifacts = record.metadata?.uiArtifacts;
if (!Array.isArray(artifacts)) return undefined;
return artifacts.filter(
(item): item is AiArtifactSchema =>
Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string',
);
}
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
const artifacts = historyArtifacts(record);
const baseMessage = {
id: record.id,
role: record.role,
content: record.content || '',
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
forms: historyForms(record),
reviews: historyReviews(record),
charts: historyCharts(record),
replyToMessageId: record.replyToMessageId,
metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled',
};
if (artifacts) {
for (const artifact of artifacts) {
mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact);
}
}
return {
id: record.id,
status: mapStatus(record),
message: {
id: record.id,
role: record.role,
content: record.content || '',
reasoningContent: record.reasoningContent || '',
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
attachments: record.attachments ?? [],
forms: historyForms(record),
reviews: historyReviews(record),
charts: historyCharts(record),
replyToMessageId: record.replyToMessageId,
metadata: record.metadata,
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
cancelled: record.status === 'cancelled',
},
message: baseMessage as AiChatMessage,
};
}

View File

@@ -154,6 +154,42 @@ describe('AI chat SSE message reducer', () => {
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
});
it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => {
let message = reduceAiSseMessage(undefined, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 12,
artifact: {
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { id: 'form-1', title: '新增学生', fields: [] },
},
}),
});
message = reduceAiSseMessage(message, {
event: 'ui.artifact',
data: JSON.stringify({
messageId: 12,
artifact: {
id: 'review-1',
type: 'review',
status: 'expired',
messageId: 12,
conversationId: 3,
payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] },
},
}),
});
expect(message.uiArtifacts).toHaveLength(2);
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' });
});
it('restores a persisted form from message.completed metadata', () => {
const message = reduceAiSseMessage(undefined, {
event: 'message.completed',

View File

@@ -6,231 +6,10 @@ import {
} from '@ant-design/x-sdk';
import { usePermissionStore } from '../../store/permission/permissionStore';
import { useUserStore } from '../../store/user/userStore';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChartSchema,
AiFormSchema,
AiModelRetryInfo,
AiReviewSchema,
AiSseChunk,
AiToolRun,
} from './types';
import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types';
import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer';
interface AiSsePayload {
messageId?: number;
userMessageId?: number;
assistantMessageId?: number;
delta?: string;
content?: string;
reasoningContent?: string | null;
toolCallId?: string;
toolName?: string;
skillKey?: string | null;
status?: string;
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
};
}
function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
return next;
}
function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
} else if (event === 'tool.failed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
} else if (event === 'attachment.processed' && payload.attachment) {
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
message.attachments = [...message.attachments, payload.attachment];
}
} else if (event === 'message.completed') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.messageId ?? message.id;
message.content = nested?.content ?? payload.content ?? message.content;
message.reasoningContent =
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
message.retrying = null;
} else if (event === 'error') {
message.retrying = null;
message.error =
(typeof payload.message === 'string' ? payload.message : undefined) ||
payload.error ||
'AI 回答生成失败';
}
return message;
}
export { parseSsePayload, reduceAiSseMessage };
export async function authenticatedFetch(
input: RequestInfo | URL,
@@ -315,6 +94,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
> {
/** Routes events that target another (already streamed) message. */
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void;
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
super({
@@ -400,6 +180,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
const { event, payload } = parseSsePayload(info.chunk);
if (
event === 'ui.artifact' &&
payload.artifact &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
this.onExternalArtifact?.(payload.messageId, payload.artifact);
return info.originMessage ?? emptyAssistant();
}
if (
event === 'ui.form' &&
payload.form &&
typeof payload.messageId === 'number' &&
info.originMessage?.id !== payload.messageId
) {
this.onExternalArtifact?.(payload.messageId, {
id: payload.form.id,
type: 'form',
status: payload.form.status ?? 'pending',
messageId: payload.messageId,
payload: payload.form,
});
return info.originMessage ?? emptyAssistant();
}
if (
event === 'ui.review' &&
payload.review &&

View File

@@ -0,0 +1,204 @@
import type {
AiArtifactSchema,
AiAttachment,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiModelRetryInfo,
AiReviewSchema,
AiSseChunk,
AiToolRun,
} from './types';
import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts';
export interface AiSsePayload {
messageId?: number;
userMessageId?: number;
assistantMessageId?: number;
delta?: string;
content?: string;
reasoningContent?: string | null;
toolCallId?: string;
toolName?: string;
skillKey?: string | null;
status?: string;
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
artifact?: AiArtifactSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
export function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
uiArtifacts: [],
};
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
const artifacts = nested.metadata?.uiArtifacts;
if (Array.isArray(artifacts)) {
for (const artifact of artifacts) {
if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') {
mergeArtifactIntoMessage(message, artifact as AiArtifactSchema);
}
}
}
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.artifact' && payload.artifact) {
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_wizard' && payload.wizard) {
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
} else if (event === 'tool.failed') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
} else if (event === 'attachment.processed' && payload.attachment) {
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
message.attachments = [...message.attachments, payload.attachment];
}
} else if (event === 'message.completed') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.messageId ?? message.id;
message.content = nested?.content ?? payload.content ?? message.content;
message.reasoningContent =
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
message.retrying = null;
} else if (event === 'message.cancelled') {
message.id = payload.messageId ?? message.id;
message.cancelled = true;
message.retrying = null;
} else if (event === 'error') {
message.retrying = null;
message.error =
(typeof payload.message === 'string' ? payload.message : undefined) ||
payload.error ||
'AI 回答生成失败';
}
return message;
}

View File

@@ -165,6 +165,18 @@
min-height: 0;
}
.ai-chat-main .ai-chat-toolbar {
order: 0;
}
.ai-chat-main .ai-chat-messages {
order: 1;
}
.ai-chat-main .ai-chat-composer {
order: 2;
}
.ai-chat-toolbar {
display: flex;
flex: 0 0 48px;

View File

@@ -52,7 +52,7 @@ export interface AiFormSchema {
description?: string | null;
submitLabel?: string;
fields: AiFormField[];
status?: 'pending' | 'submitted';
status?: 'pending' | 'submitted' | 'expired';
}
export interface AiReviewColumn {
@@ -98,6 +98,26 @@ export interface AiChartSchema {
rows: AiReviewRow[];
}
export type AiArtifactType =
| 'form'
| 'review'
| 'chart'
| 'import_wizard';
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
export interface AiArtifactSchema<T = unknown> {
id: string;
type: AiArtifactType;
status: AiArtifactStatus;
messageId: number;
conversationId?: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}
export interface AiImportWizard {
runId: string;
fileName: string;
@@ -154,6 +174,7 @@ export interface AiChatMessage {
forms?: AiFormSchema[];
reviews?: AiReviewSchema[];
charts?: AiChartSchema[];
uiArtifacts?: AiArtifactSchema[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
retrying?: AiModelRetryInfo | null;

View File

@@ -0,0 +1,67 @@
import type {
AiArtifactSchema,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiReviewSchema,
} from './types';
export function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
export function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
return next;
}
function payloadOf(artifact: AiArtifactSchema): unknown {
return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {};
}
/**
* 将统一 artifact 归入 uiArtifacts并按类型派发到 legacy 列表。
* payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。
*/
export function mergeArtifactIntoMessage(
message: AiChatMessage,
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
const payload = payloadOf(artifact);
if (artifact.type === 'form') {
message.forms = mergeForms(message.forms, payload as AiFormSchema);
} else if (artifact.type === 'review') {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
} else if (artifact.type === 'chart') {
message.charts = mergeById<AiChartSchema>(message.charts, payload as AiChartSchema);
} else if (artifact.type === 'import_wizard') {
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
}
return message;
}

View File

@@ -10,6 +10,7 @@ import { aiChatApi } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import { mergeArtifactIntoMessage } from './uiArtifacts';
import {
emptyAssistant,
MessageHoverActions,
@@ -110,6 +111,11 @@ export function useAiChatMessageActions({
},
}));
};
provider.onExternalArtifact = (messageId, artifact) => {
setMessage(messageId, (info) => ({
message: mergeArtifactIntoMessage(info.message, artifact),
}));
};
}, [provider, setMessage]);
requestingRef.current = isRequesting;
@@ -271,7 +277,7 @@ export function useAiChatMessageActions({
},
});
},
[activeId, isRequesting, refreshConversations, removeMessage],
[activeId, isRequesting, refreshConversations, removeMessage, modal],
);
const confirmEditMessage = useCallback(

View File

@@ -50,13 +50,15 @@ interface EChartsProps {
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
const containerRef = useRef<HTMLDivElement>(null);
const optionRef = useRef(option);
optionRef.current = option;
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
useEffect(() => {
if (!containerRef.current) return;
const chart = echarts.init(containerRef.current);
chart.setOption(option);
chart.setOption(optionRef.current);
onReadyRef.current?.(chart);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);

View File

@@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
try {
const detail = await createImportRun(file, {
source: 'manual',
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
});
await loadRun(detail.id);
@@ -390,7 +390,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
<Alert
type="info"
showIcon
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
title="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
/>
<Upload.Dragger
accept=".xlsx,.csv"
@@ -456,7 +456,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
</Flex>
) : allCommitted ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Alert type="success" showIcon message="全部阶段已提交完成" />
<Alert type="success" showIcon title="全部阶段已提交完成" />
<Descriptions
bordered
size="small"
@@ -607,7 +607,7 @@ export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
)}
</Space>
) : (
<Alert type="warning" showIcon message="当前没有可处理的阶段" />
<Alert type="warning" showIcon title="当前没有可处理的阶段" />
)}
</Space>
)}

View File

@@ -36,11 +36,21 @@ export interface ImportRunDetail {
createdAt: string;
sheets: ImportSheetMeta[];
steps: ImportStepDetail[];
settings?: {
mapping?: Partial<Record<ImportStepKey, Record<string, string>>>;
organization?: string | null;
updateExisting?: boolean;
duplicatePolicy?: 'error' | 'skip';
skipUnmatched?: boolean;
};
}
export interface ImportStageRequest {
stepKey: ImportStepKey;
/** 兼容旧调用:单个工作表名。 */
sheet?: string;
/** 一个阶段可包含多张工作表;与 sheet 二选一sheets 优先)。 */
sheets?: string[];
headerRow?: number;
}

View File

@@ -329,7 +329,7 @@ const JinshujuMatchModal: React.FC<MatchModalProps> = ({ open, onClose, onApplie
open={open && canEnterModal}
onCancel={handleClose}
width={step === 'match' || step === 'applying' ? 900 : 640}
maskClosable={false}
mask={{ closable: false }}
footer={
step === 'connection'
? [

View File

@@ -29,9 +29,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
);
const uploadAttachmentMutation = useApiMutation(
async (formData: FormData) =>
api.post(`/archive/${studentId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post(`/archive/${studentId}/attachments`, formData),
{ invalidate: [['archive', studentId]] },
);

View File

@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
},
});
},
[studentId, module],
[studentId, module, modal],
);
}

View File

@@ -36,6 +36,7 @@ import api from '../api';
import { useAppStore } from '../store/app/appStore';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore';
import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist';
import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper';
@@ -125,9 +126,42 @@ const MainLayout: React.FC = () => {
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== 'token' && event.key !== 'permissions') return;
usePermissionStore.getState().beginPermissionVerification();
window.location.reload();
if (event.key === PERMISSION_STORAGE_NAME) {
// 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。
try {
if (event.newValue === null) {
usePermissionStore.getState().clearPermissions();
return;
}
const parsed = JSON.parse(event.newValue) as {
state?: { permissions?: string[] };
};
const permissions = parsed?.state?.permissions;
if (Array.isArray(permissions)) {
usePermissionStore.getState().writePermissions(permissions);
}
} catch {
// 忽略无法解析的跨标签页权限写入
}
return;
}
if (event.key === AUTH_STORAGE_NAME) {
// 仅当登录态token确实变化时才整页刷新登录、退出或切换账号。
const currentToken = useUserStore.getState().token;
let otherToken: string | null = null;
if (event.newValue) {
try {
const parsed = JSON.parse(event.newValue) as {
state?: { token?: string | null };
};
otherToken = parsed?.state?.token ?? null;
} catch {
otherToken = null;
}
}
if (otherToken === currentToken) return;
window.location.reload();
}
};
const handleOnline = () => verifyPermissions();
const handleVisibilityChange = () => {
@@ -169,19 +203,22 @@ const MainLayout: React.FC = () => {
navigate(key);
if (usesDrawer) setDrawerOpen(false);
},
[navigate, usesDrawer],
[navigate, usesDrawer, setDrawerOpen],
);
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.key === pathname) return [item.key];
if (item.children) {
const found = findSelectedKeys(item.children, pathname);
if (found.length > 0) return found;
const findSelectedKeys = useCallback(
(items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.key === pathname) return [item.key];
if (item.children) {
const found = findSelectedKeys(item.children, pathname);
if (found.length > 0) return found;
}
}
}
return [pathname];
};
return [pathname];
},
[],
);
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
@@ -201,7 +238,7 @@ const MainLayout: React.FC = () => {
const selectedKeys = useMemo(
() => findSelectedKeys(menuItems, location.pathname),
[menuItems, location.pathname],
[menuItems, location.pathname, findSelectedKeys],
);
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
useEffect(() => {
@@ -210,20 +247,20 @@ const MainLayout: React.FC = () => {
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
}
}, [location.pathname, menuItems]);
}, [location.pathname, menuItems, setOpenKeys]);
const handleOpenChange = useCallback((keys: string[]) => {
setOpenKeys(keys);
}, []);
}, [setOpenKeys]);
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {
return items.map((item) => ({
key: item.key,
icon: item.icon ? iconMap[item.icon] : undefined,
label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined,
}));
};
}, []);
const menuContent = useMemo(
() => (
<Menu
@@ -237,7 +274,7 @@ const MainLayout: React.FC = () => {
style={{ border: 'none' }}
/>
),
[selectedKeys, openKeys, menuItems, handleMenuClick],
[selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems],
);
return (

View File

@@ -37,7 +37,10 @@ const queryClient = new QueryClient({
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('未找到 #root 挂载点');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />

View File

@@ -355,7 +355,7 @@ export const SaveTestStep: React.FC<{
<Card title={<span className={styles.cardTitle}></span>} extra={<CheckCircleOutlined />}>
<Alert
type="info"
message="配置预览"
title="配置预览"
description={
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
<Descriptions.Item label="服务商">

View File

@@ -48,10 +48,10 @@ const DEFAULT_FORM_VALUES: FormValues = {
};
const STEP_ITEMS = [
{ title: '服务商', description: '选择 AI 服务商' },
{ title: '密钥', description: '配置 API 密钥' },
{ title: '模型', description: '获取并选择模型' },
{ title: '完成', description: '保存并测试连接' },
{ title: '服务商', content: '选择 AI 服务商' },
{ title: '密钥', content: '配置 API 密钥' },
{ title: '模型', content: '获取并选择模型' },
{ title: '完成', content: '保存并测试连接' },
];
const AiConfigPage: React.FC = () => {
@@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => {
} finally {
setTesting(false);
}
}, [formValues, form, currentProvider]);
}, [formValues, form, currentProvider, refreshConfig]);
// ── Clear key ──
@@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => {
}
},
});
}, [config, modal]);
}, [config, modal, clearKeyMutation]);
// ── Step navigation ──

View File

@@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
<section className="student-class-overview" aria-label="班级考勤汇总">
<div className="student-class-identity">
<div className="student-class-heading">
<span className="student-overview-kicker"></span>
<h2>{selectedClass}</h2>
<p>
{dateLabel} · {visibleStudentCount} / {total}

View File

@@ -134,7 +134,7 @@ export const AttendanceAdminWorkspace: React.FC<{
</Spin>
</section>
<Card className="student-record-card" bordered={false} title="原始考勤明细">
<Card className="student-record-card" variant="borderless" title="原始考勤明细">
<Table<AttendanceRecordItem>
rowKey="id"
columns={columns}

View File

@@ -148,7 +148,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
destroyOnHidden
>
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
<p>
{className} · {displayedSchedule?.startTime}{displayedSchedule?.endTime} ·{' '}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
@@ -115,10 +115,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
enabled: !!classId && !!attendanceDate,
queryFn: async () => {
try {
if (!attendanceDate) return [];
return validateResponse<HistoryScheduleOption[]>(
attendanceScheduleOptionsSchema,
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') },
params: { classId, date: attendanceDate.format('YYYY-MM-DD') },
}),
);
} catch (error: unknown) {
@@ -133,10 +134,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
? scheduleId
: undefined;
useEffect(() => {
periodForm.setFieldsValue({ periods });
}, [periods, periodForm]);
const enabledPeriods = useMemo(
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
[periods],
@@ -214,7 +211,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
return params;
},
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
[page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId],
);
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
@@ -334,7 +331,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
} finally {
setRefreshingDingTalk(false);
}
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]);
const resetFilters = () => {
setClassId(undefined);

View File

@@ -761,7 +761,6 @@
padding: 0 24px;
border-bottom: 1px solid var(--student-line);
background: rgb(255 255 255 / 96%);
backdrop-filter: blur(10px);
}
.student-center-title {
@@ -911,21 +910,7 @@
min-height: 176px;
overflow: hidden;
padding: 20px;
background:
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
}
.student-class-identity::after {
content: '';
position: absolute;
right: -36px;
bottom: -44px;
width: 118px;
height: 118px;
border: 18px solid rgb(21 122 101 / 7%);
border-radius: 999px;
pointer-events: none;
background: var(--student-surface);
}
.student-class-heading {
@@ -933,19 +918,6 @@
z-index: 1;
}
.student-overview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
margin-bottom: 8px;
padding: 0 9px;
border-radius: 999px;
background: rgb(21 122 101 / 10%);
color: var(--student-primary);
font-size: 12px;
font-weight: 700;
}
.student-class-identity h2 {
margin: 0 0 6px;
color: #111c18;

View File

@@ -4,6 +4,7 @@ import { useUserStore } from '../../store/user/userStore';
import { getAttendanceExperience } from './attendance-workspace';
import { TeacherAttendanceWorkspace } from './teacher';
import { AdminAttendanceArchive } from './admin';
import './attendance.css';
function readCurrentRoles(): string[] {
const roles = useUserStore.getState().user?.roles;

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import {
App,
Table,
@@ -139,7 +139,7 @@ const BillsPage: React.FC = () => {
}
};
const showDetail = async (id: number) => {
const showDetail = useCallback(async (id: number) => {
setDetailLoading(true);
try {
const res = await api.get(`/bills/${id}`);
@@ -149,60 +149,69 @@ const BillsPage: React.FC = () => {
} finally {
setDetailLoading(false);
}
};
}, []);
const handleCancel = async (id: number) => {
let reason = '';
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
placeholder="请输入取消原因"
maxLength={300}
onChange={(event) => {
reason = event.target.value;
}}
/>
),
okText: '确认取消',
cancelText: '返回',
onOk: async () => {
if (!reason.trim()) {
message.error('请输入取消原因');
throw new Error('reason required');
}
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
},
});
};
const handleCancel = useCallback(
(id: number) => {
let reason = '';
modal.confirm({
title: '取消账单并退回已扣余额',
content: (
<Input.TextArea
placeholder="请输入取消原因"
maxLength={300}
onChange={(event) => {
reason = event.target.value;
}}
/>
),
okText: '确认取消',
cancelText: '返回',
onOk: async () => {
if (!reason.trim()) {
message.error('请输入取消原因');
throw new Error('reason required');
}
await cancelMutation.mutateAsync({ id, reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
},
});
},
[modal, cancelMutation],
);
const handleArchive = async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('账单已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = (id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, studentName: string, period: string) => {
modal.confirm({
title: `永久删除账单(${studentName} ${period}`,
content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
@@ -226,7 +235,7 @@ const BillsPage: React.FC = () => {
);
};
const handleExportPdf = async (billId: number) => {
const handleExportPdf = useCallback(async (billId: number) => {
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
@@ -245,7 +254,7 @@ const BillsPage: React.FC = () => {
printWindow.close();
message.error(error?.message || '账单加载失败');
}
};
}, []);
const columns = useMemo(
() => [

View File

@@ -531,22 +531,22 @@ export const ClassAttendanceTab: React.FC<{
{attendanceSummary && (
<Row gutter={[16, 16]}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Card variant="borderless">
<Statistic title="总记录" value={attendanceSummary.total} />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Card variant="borderless">
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Card variant="borderless">
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
</Card>
</Col>
<Col xs={12} md={6}>
<Card bordered={false}>
<Card variant="borderless">
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
</Card>
</Col>

View File

@@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleArchive = async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const {
data = [],
isLoading,
@@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => {
{ invalidate: [['classes']] },
);
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = useCallback(
(record: ClassItem) => {
modal.confirm({
title: `永久删除班级「${record.name}」?`,
content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const filtered = useMemo(() => {
if (!searchText) return data;
const q = searchText.toLowerCase();
@@ -174,16 +180,19 @@ const ClassesPage: React.FC = () => {
setModalOpen(true);
};
const handleEdit = (record: ClassItem) => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});
setModalOpen(true);
};
const handleEdit = useCallback(
(record: ClassItem) => {
setEditing(record);
form.setFieldsValue({
...record,
notes: record.notes ?? undefined,
startDate: record.startDate ? dayjs(record.startDate) : undefined,
endDate: record.endDate ? dayjs(record.endDate) : undefined,
});
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
@@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => {
),
},
],
[saveCell, canPurgeClass, handlePurge],
[saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive],
);
return (

View File

@@ -140,9 +140,7 @@ const ClassroomRentalsPage: React.FC = () => {
);
const uploadContractMutation = useApiMutation(
async ({ id, formData }: { id: number; formData: FormData }) =>
api.post(`/classroom-rentals/${id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post(`/classroom-rentals/${id}/contract`, formData),
{ invalidate: [['classroom-rentals']] },
);
@@ -198,7 +196,7 @@ const ClassroomRentalsPage: React.FC = () => {
}
}
},
[],
[setUnavailableDates],
);
const handleClassroomChange = (classroomId: number) => {

View File

@@ -16,7 +16,7 @@ import {
Empty,
Tooltip,
} from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
@@ -65,7 +65,7 @@ const ClassroomSchedulePage: React.FC = () => {
for (const c of data.classrooms) {
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c);
map.get(key)?.push(c);
}
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
}, [data]);
@@ -314,7 +314,13 @@ const ClassroomSchedulePage: React.FC = () => {
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? '📖' : cell.hasContract ? '📄' : ''}
{isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span>
</Tooltip>
)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
@@ -110,9 +110,7 @@ const ClassroomsPage: React.FC = () => {
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post('/classrooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post('/classrooms/import', formData),
{ invalidate: [['classrooms']] },
);
@@ -147,50 +145,63 @@ const ClassroomsPage: React.FC = () => {
}
};
const saveCell = async (record: any, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[saveCellMutation],
);
const handleArchive = async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handleRestore = async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleRestore = useCallback(
async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[restoreMutation],
);
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除教室「${name}」?`,
content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({
title: `永久删除教室「${name}」?`,
content:
'删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD
@@ -397,7 +408,7 @@ const ClassroomsPage: React.FC = () => {
),
},
],
[handlePurge, hasPermission],
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form],
);
return (

View File

@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
}
export interface GanttOccupancy {
studentName: string;
studentId?: string;
studentId?: string | number;
checkInDate: string;
checkOutDate: string | null;
billingStartDate?: string;

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { buildGanttOption } from './DashboardCharts';
import type { GanttOccupancy } from './Dashboard.types';
const ganttRoom = (occupancies: GanttOccupancy[]) => [
{ roomNumber: 'A101', occupancies },
];
describe('buildGanttOption 甘特图时间线', () => {
it('未退宿的入住条在查看过去月份时截断到 periodEnd而不是画到今天', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '张三',
checkInDate: '2026-05-01',
checkOutDate: null,
},
]),
{ periodEnd: '2026-06-30', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-06-30');
expect(series[0].data[0].value[3]).toBe(true);
});
it('未退宿的入住条在查看当前月时截断到今天', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '张三',
checkInDate: '2026-07-01',
checkOutDate: null,
},
]),
{ periodEnd: '2026-08-31', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-08-07');
});
it('已退宿的入住条保留真实退宿日期', () => {
const option = buildGanttOption(
ganttRoom([
{
studentName: '李四',
checkInDate: '2026-05-01',
checkOutDate: '2026-06-15',
},
]),
{ periodEnd: '2026-06-30', today: '2026-08-07' },
);
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
expect(series[0].data[0].value[2]).toBe('2026-06-15');
expect(series[0].data[0].value[3]).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import type { EChartsOption } from '../../components/ECharts';
import dayjs from 'dayjs';
import {
attendanceLabelMap,
COLORS,
@@ -201,7 +202,13 @@ export function buildClassroomHeatmapOption(
};
}
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
export function buildGanttOption(
ganttData: GanttRoom[],
options?: { periodEnd?: string; today?: string },
): EChartsOption {
const today = options?.today ?? dayjs().format('YYYY-MM-DD');
const periodEnd = options?.periodEnd;
return {
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
@@ -246,15 +253,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
},
encode: { x: [1, 2], y: 0 },
data: ganttData.flatMap((r) =>
(r.occupancies || []).map((o) => ({
name: o.studentName,
value: [
r.roomNumber,
o.checkInDate,
o.checkOutDate || new Date().toISOString().slice(0, 10),
!o.checkOutDate,
] as [string, string, string, boolean],
})),
(r.occupancies || []).map((o) => {
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
return {
name: o.studentName,
value: [
r.roomNumber,
o.checkInDate,
o.checkOutDate || activeEnd,
!o.checkOutDate,
] as [string, string, string, boolean],
};
}),
),
},
],

View File

@@ -61,10 +61,11 @@ export const ClassroomHeatmapCard: React.FC<{
);
};
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
data,
isMobile,
}) => {
export const GanttCard: React.FC<{
data: GanttRoom[];
isMobile: boolean;
periodEnd?: string;
}> = ({ data, isMobile, periodEnd }) => {
const vp = useInViewport('200px');
return (
<LazySection
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
>
{data.length > 0 ? (
<ReactECharts
option={buildGanttOption(data)}
option={buildGanttOption(data, { periodEnd })}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { shouldNavigateTodoCard } from './DashboardTodoCards';
describe('DashboardTodoCards 点击导航守卫', () => {
it('目标路径与当前路径不同且冷却已过时允许导航', () => {
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1600)).toBe(true);
});
it('目标路径与当前路径相同时不允许导航', () => {
expect(shouldNavigateTodoCard('/attendance', '/attendance', 1000, 1600)).toBe(false);
});
it('冷却期内忽略重复点击,防止产生多条历史记录', () => {
expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1300)).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { Card, Col, Row } from 'antd';
import {
ArrowRightOutlined,
@@ -6,9 +6,22 @@ import {
DollarOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { useLocation, useNavigate } from 'react-router';
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
/** 冷却期内忽略重复点击,避免快速/幽灵点击压入多条历史记录。 */
const TODO_CLICK_LOCK_MS = 400;
export function shouldNavigateTodoCard(
currentPath: string,
targetPath: string,
lastNavAt: number,
now: number,
): boolean {
if (currentPath === targetPath) return false;
return now - lastNavAt >= TODO_CLICK_LOCK_MS;
}
export const DashboardTodoCards: React.FC<{
absentCount: number;
draftCount: number;
@@ -16,6 +29,14 @@ export const DashboardTodoCards: React.FC<{
pendingDeposits: number;
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
const navigate = useNavigate();
const location = useLocation();
const lastNavAtRef = useRef(0);
const go = (target: string) => {
const now = Date.now();
if (!shouldNavigateTodoCard(location.pathname, target, lastNavAtRef.current, now)) return;
lastNavAtRef.current = now;
navigate(target);
};
return (
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}>
@@ -23,7 +44,7 @@ export const DashboardTodoCards: React.FC<{
<Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')}
onClick={() => go('/attendance')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ExclamationCircleOutlined
@@ -55,7 +76,7 @@ export const DashboardTodoCards: React.FC<{
<Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')}
onClick={() => go('/bills')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<DollarOutlined
@@ -87,7 +108,7 @@ export const DashboardTodoCards: React.FC<{
<Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')}
onClick={() => go('/deposits')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<BankOutlined

View File

@@ -186,7 +186,8 @@ const DashboardPage: React.FC = () => {
aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
if (dates?.[0] && dates?.[1])
setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
}}
/>
</div>
@@ -494,7 +495,7 @@ const DashboardPage: React.FC = () => {
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
<GanttCard data={ganttData} isMobile={isMobile} />
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
</div>
);
};

View File

@@ -97,7 +97,11 @@ const DepositsPage: React.FC = () => {
{ invalidate: [['deposits']] },
);
const payInstallmentMutation = useApiMutation(
async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`),
async (installmentId: number) =>
api.put(`/deposits/installments/${installmentId}`, {
status: 'paid',
paidDate: dayjs().format('YYYY-MM-DD'),
}),
{ invalidate: [['deposits']] },
);
const saveInstallmentCellMutation = useApiMutation(
@@ -135,7 +139,7 @@ const DepositsPage: React.FC = () => {
if (eligibleRoomType) params.roomType = eligibleRoomType;
return validateResponse<EligibleStudent[]>(
eligibleStudentsSchema,
await api.get('/deposits/eligible', { params }),
await api.get('/deposits/eligible-students', { params }),
);
},
});

View File

@@ -163,7 +163,7 @@ const ExamDetailPage: React.FC = () => {
</Space>
</div>
{detail.status === 'archived' ? (
<Alert type="info" showIcon message="该考试已归档,成绩仅供查看。如需继续录入,请先在考试列表中恢复。" />
<Alert type="info" showIcon title="该考试已归档,成绩仅供查看。如需继续录入,请先在考试列表中恢复。" />
) : null}
<Card className="exam-summary">
<Descriptions column={{ xs: 1, sm: 2, lg: 5 }}>

View File

@@ -9,7 +9,12 @@ import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas';
import {
expenseLookupsSchema,
expenseRecordsSchema,
expenseRoomsListSchema,
expenseStudentLookupsSchema,
} from '../../api/schemas';
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
import { ExpenseTablePanel } from './ExpenseTablePanel';
import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals';
@@ -70,10 +75,10 @@ const ExpensesPage: React.FC = () => {
try {
const [rooms, personal, students, roomsList] = await Promise.all([
api.get('/expenses/room', {
params: expenseStatusForView(showArchived ? 'archived' : 'active'),
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/personal', {
params: expenseStatusForView(showArchived ? 'archived' : 'active'),
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/student-lookups'),
api.get('/rooms'),
@@ -81,8 +86,8 @@ const ExpensesPage: React.FC = () => {
return {
rooms: validateResponse(expenseRecordsSchema, rooms),
personal: validateResponse(expenseRecordsSchema, personal),
students: validateResponse(expenseRecordsSchema, students),
roomsList: validateResponse(expenseRecordsSchema, roomsList),
students: validateResponse(expenseStudentLookupsSchema, students),
roomsList: validateResponse(expenseRoomsListSchema, roomsList),
};
} catch {
message.error('加载费用数据失败');
@@ -127,21 +132,17 @@ const ExpensesPage: React.FC = () => {
{ invalidate: [['expenses']] },
),
utility: useApiMutation(
async (payload: Record<string, unknown>) => api.post('/expenses/utility', payload),
async (payload: Record<string, unknown>) => api.post('/expenses/student-utility', payload),
{ invalidate: [['expenses'], ['bills']] },
),
importUtility: useApiMutation(
async (formData: FormData) =>
api.post('/expenses/utility/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post('/expenses/utility/import', formData),
{ invalidate: [['expenses']] },
),
importPersonal: useApiMutation(
async (formData: FormData) =>
api.post('/expenses/personal/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post('/expenses/personal/import', formData),
{ invalidate: [['expenses']] },
),
archiveRoom: useApiMutation(
@@ -161,11 +162,11 @@ const ExpensesPage: React.FC = () => {
{ invalidate: [['expenses']] },
),
batchRestoreRoom: useApiMutation(
async (ids: number[]) => api.post('/expenses/room/batch-restore', { ids }),
async (ids: number[]) => api.put('/expenses/room/batch-restore', { ids }),
{ invalidate: [['expenses']] },
),
batchRestorePersonal: useApiMutation(
async (ids: number[]) => api.post('/expenses/personal/batch-restore', { ids }),
async (ids: number[]) => api.put('/expenses/personal/batch-restore', { ids }),
{ invalidate: [['expenses']] },
),
purgeRoom: useApiMutation(

View File

@@ -171,7 +171,7 @@ const IntegrationConfigPage: React.FC = () => {
<Alert
type="info"
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
title="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
style={{ marginBottom: 24 }}
showIcon
/>

View File

@@ -212,7 +212,7 @@ export const CheckInModal: React.FC<{
label="押金金额"
rules={[{ required: true, message: '请输入押金金额' }]}
>
<InputNumber min={0.01} precision={2} addonAfter="元" style={{ width: '100%' }} />
<InputNumber min={0.01} precision={2} suffix="元" style={{ width: '100%' }} />
</Form.Item>
) : null
}

View File

@@ -369,23 +369,26 @@ const OccupanciesPage: React.FC = () => {
}
};
const handlePurge = (id: number, studentName: string) => {
modal.confirm({
title: `永久删除入住记录(${studentName}`,
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, studentName: string) => {
modal.confirm({
title: `永久删除入住记录(${studentName}`,
content: '删除后不可恢复,该入住记录将被物理删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleBatchPurge = async () => {
if (batchLoading) return;

View File

@@ -44,9 +44,7 @@ export function useOccupancyMutations() {
);
const importMutation = useApiMutation(
async ({ formData, params }: { formData: FormData; params: string }) =>
api.post(`/occupancies/import?${params}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post(`/occupancies/import?${params}`, formData),
{ invalidate: invalidateOccupancies },
);

View File

@@ -153,8 +153,8 @@ const OperationLogsPage: React.FC = () => {
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
onChange={(dates) => {
if (dates)
setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
if (dates?.[0] && dates?.[1])
setDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
else setDateRange(null);
setPage(1);
}}

View File

@@ -349,7 +349,7 @@ const OrganizationsPage: React.FC = () => {
<Alert
type="info"
showIcon
message="统一机构管理"
title="统一机构管理"
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
style={{ marginBottom: 16 }}
/>
@@ -417,7 +417,7 @@ const OrganizationsPage: React.FC = () => {
<Alert
type="warning"
showIcon
message="这是系统本机构,不可归档,也不能改为外部机构。"
title="这是系统本机构,不可归档,也不能改为外部机构。"
style={{ marginBottom: 16 }}
/>
) : null}

View File

@@ -92,12 +92,15 @@ const RolesPage: React.FC = () => {
setModalOpen(true);
};
const handleEdit = (record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map((p) => p.id));
setModalOpen(true);
};
const handleEdit = useCallback(
(record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map((p) => p.id));
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
@@ -117,14 +120,17 @@ const RolesPage: React.FC = () => {
}
};
const handleDisable = async (id: number) => {
try {
await disableMutation.mutateAsync(id);
message.success('角色已停用');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDisable = useCallback(
async (id: number) => {
try {
await disableMutation.mutateAsync(id);
message.success('角色已停用');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[disableMutation],
);
const groupNames: Record<string, string> = {
dashboard: '数据面板',
@@ -264,7 +270,7 @@ const RolesPage: React.FC = () => {
),
},
],
[permissionOptions, saveCell],
[permissionOptions, saveCell, handleEdit, handleDisable],
);
const handleGroupCheckAll = (group: string, checked: boolean) => {

View File

@@ -102,7 +102,7 @@ const RoomVisualPage: React.FC = () => {
queryKey: ['rooms', 'visual', isHistorical, asOf],
queryFn: async () => {
try {
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined;
return await api.get('/rooms/visual', { params });
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
@@ -229,7 +229,7 @@ const RoomVisualPage: React.FC = () => {
showIcon
icon={<HistoryOutlined />}
style={{ marginBottom: 16 }}
title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`}
title={`正在查看 ${asOf ? asOf.format('YYYY年M月D日') : ''} 的历史入住情况(含当日已归档房间),非实时数据`}
action={
<Button size="small" type="link" onClick={() => setAsOf(null)}>

View File

@@ -143,9 +143,7 @@ export function useRoomMutations(editing: any) {
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post<{ message?: string }>('/rooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post<{ message?: string }>('/rooms/import', formData),
{ invalidate: [['rooms']] },
);

View File

@@ -176,7 +176,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
min={0}
max={1440}
step={5}
addonAfter="分钟"
suffix="分钟"
style={{ width: '100%' }}
placeholder="例如 30"
/>
@@ -422,7 +422,7 @@ export const SyncModal: React.FC<SyncModalProps> = ({
title="排班数"
value={syncResult.syncedItems}
suffix="条"
valueStyle={{ color: '#3f8600' }}
styles={{ content: { color: '#3f8600' } }}
/>
</Col>
</Row>
@@ -480,9 +480,11 @@ export const SyncModal: React.FC<SyncModalProps> = ({
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
styles={{
content: {
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
},
}}
/>
</Col>
@@ -540,7 +542,7 @@ export const SyncModal: React.FC<SyncModalProps> = ({
<Alert
type="info"
showIcon
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
title="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
style={{ marginTop: 12 }}
/>
)}
@@ -548,7 +550,7 @@ export const SyncModal: React.FC<SyncModalProps> = ({
{syncStatus.activeSchedules === 0 && (
<Alert
type="info"
message="当前没有活跃排课。请先在排课页面创建排课记录。"
title="当前没有活跃排课。请先在排课页面创建排课记录。"
showIcon
/>
)}

View File

@@ -9,12 +9,10 @@ import api from '../../api';
const StudentProfilePage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
if (!id) return null;
const studentId = Number(id);
const handlePreviewReport = useCallback(async () => {
if (Number.isNaN(studentId)) return;
try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
const w = window.open('', '_blank');
@@ -27,6 +25,8 @@ const StudentProfilePage: React.FC = () => {
}
}, [studentId]);
if (!id) return null;
return (
<Card
title={

View File

@@ -93,10 +93,10 @@ const StudentsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [jinshujuOpen, setJinshujuOpen] = useState(false);
const openDrawer = (studentId: number) => {
const openDrawer = useCallback((studentId: number) => {
setDrawerStudentId(studentId);
setDrawerOpen(true);
};
}, []);
const logCreateRef = React.useRef(hasPermission('log:create'));
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
@@ -112,38 +112,41 @@ const StudentsPage: React.FC = () => {
};
}, []);
const handleViewSensitive = (studentId: number, field: string, value: string) => {
if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
if (!logCreateRef.current) return;
try {
await api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
modal.info({
title: field,
content: value,
okText: '关闭',
});
} catch (e) {
console.error('审计日志记录失败', e);
message.error('审计日志记录失败,请稍后重试');
}
},
afterClose: () => {
sensitiveModalRef.current = null;
},
});
};
const handleViewSensitive = useCallback(
(studentId: number, field: string, value: string) => {
if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
if (!logCreateRef.current) return;
try {
await api.post('/operation-logs/audit', {
module: '学生管理',
action: '查看敏感信息',
targetId: studentId,
targetType: 'student',
detail: `查看${field}`,
});
modal.info({
title: field,
content: value,
okText: '关闭',
});
} catch (e) {
console.error('审计日志记录失败', e);
message.error('审计日志记录失败,请稍后重试');
}
},
afterClose: () => {
sensitiveModalRef.current = null;
},
});
},
[modal],
);
const {
data = [],
@@ -225,16 +228,12 @@ const StudentsPage: React.FC = () => {
);
const importMutation = useApiMutation(
async (formData: FormData) =>
api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post('/students/import', formData),
{ invalidate: invalidateStudents },
);
const importMatchMutation = useApiMutation(
async (formData: FormData) =>
api.post('/students/import-match', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
api.post('/students/import-match', formData),
{ invalidate: invalidateStudents },
);
@@ -329,41 +328,50 @@ const StudentsPage: React.FC = () => {
}
};
const handleArchive = async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number) => {
try {
await archiveMutation.mutateAsync(id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handleRestore = async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleRestore = useCallback(
async (id: number) => {
try {
await restoreMutation.mutateAsync(id);
message.success('已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[restoreMutation],
);
const handlePurge = (id: number, name: string) => {
modal.confirm({
title: `永久删除学生「${name}」?`,
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(id: number, name: string) => {
modal.confirm({
title: `永久删除学生「${name}」?`,
content: '删除后不可恢复,该学生及其关联数据将无法找回。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleBatchDelete = async () => {
if (batchLoading) return;
@@ -450,7 +458,9 @@ const StudentsPage: React.FC = () => {
if (log?.status === 'partial') {
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
} else {
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced}`);
message.success(
log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced}`,
);
}
void queryClient.invalidateQueries({ queryKey: ['students'] });
} catch (e: unknown) {
@@ -506,6 +516,10 @@ const StudentsPage: React.FC = () => {
saveCell,
handleViewSensitive,
form,
openDrawer,
handleArchive,
handleRestore,
handlePurge,
],
);

View File

@@ -44,16 +44,19 @@ const UsersPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}
setProfileModalOpen(true);
};
const handleOpenProfile = useCallback(
async (record: any) => {
setProfileUser(record);
try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}
setProfileModalOpen(true);
},
[profileForm],
);
const handleProfileSubmit = async () => {
setSaving(true);
@@ -133,15 +136,18 @@ const UsersPage: React.FC = () => {
setModalOpen(true);
};
const handleEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
};
const handleEdit = useCallback(
(record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
@@ -161,39 +167,48 @@ const UsersPage: React.FC = () => {
setSaving(false);
}
};
const handleArchive = async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleArchive = useCallback(
async (id: number, archive: boolean) => {
try {
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[archiveMutation],
);
const handlePurge = (record: any) => {
modal.confirm({
title: `永久删除账号「${record.name || record.username}」?`,
content:
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = useCallback(
(record: any) => {
modal.confirm({
title: `永久删除账号「${record.name || record.username}」?`,
content:
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
},
[modal, purgeMutation],
);
const handleResetPwd = (record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
};
const handleResetPwd = useCallback(
(record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
},
[pwdForm],
);
const handlePwdSubmit = async () => {
setSaving(true);
@@ -236,7 +251,18 @@ const UsersPage: React.FC = () => {
disabled={r.isArchived}
onSave={(next) => saveCell(r, USER_FIELDS.username, next)}
>
{v}
<span
title={v}
style={{
display: 'block',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{v}
</span>
</EditableCell>
),
},
@@ -353,7 +379,16 @@ const UsersPage: React.FC = () => {
),
},
],
[roles, saveCell, canPurgeUser, handlePurge],
[
roles,
saveCell,
canPurgeUser,
handlePurge,
handleOpenProfile,
handleEdit,
handleArchive,
handleResetPwd,
],
);
return (

View File

@@ -123,10 +123,13 @@ const WalletsPage: React.FC = () => {
setSelectedRowKeys([]);
};
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const openChange = useCallback(
(row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
},
[form, setSelected],
);
const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
@@ -189,20 +192,23 @@ const WalletsPage: React.FC = () => {
}
};
const showTransactions = async (row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
};
const showTransactions = useCallback(
async (row: WalletRow) => {
setSelected(row);
setDrawerOpen(true);
try {
setTransactions(
await api.get<WalletTransaction[]>('/wallets/transactions', {
params: { studentId: row.studentId },
}),
);
} catch (error: unknown) {
console.error('加载余额流水失败', error);
message.error('加载流水失败');
}
},
[setSelected, setDrawerOpen, setTransactions],
);
const columns = useMemo(
() => [
@@ -211,7 +217,6 @@ const WalletsPage: React.FC = () => {
render: (_: unknown, row: WalletRow) => (
<>
<strong>{row.studentName}</strong>
<div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div>
<div style={{ color: '#999' }}>
{row.roomType ? `${row.roomType}${row.roomNumber ? ` · ${row.roomNumber}` : ''}` : '未入住'}
</div>
@@ -257,7 +262,7 @@ const WalletsPage: React.FC = () => {
),
},
],
[],
[openChange, showTransactions],
);
return (
@@ -342,7 +347,7 @@ const WalletsPage: React.FC = () => {
extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
<InputNumber precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="description" label="备注">
@@ -378,7 +383,7 @@ const WalletsPage: React.FC = () => {
extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
<InputNumber precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="description" label="备注">

View File

@@ -41,7 +41,6 @@
"@nestjs/schedule": "^6.1.3",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"@officecli/officecli": "^1.0.143",
"@types/multer": "^2.1.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",

View File

@@ -0,0 +1,318 @@
import type {
BusinessEntity,
BusinessEntityField,
BusinessEntityRelation,
BusinessWorkflow,
} from './business-context.types';
function field(
key: string,
label: string,
type: BusinessEntityField['type'],
extra: Partial<Omit<BusinessEntityField, 'key' | 'label' | 'type'>> = {},
): BusinessEntityField {
return { key, label, type, ...extra };
}
function relation(
entityKey: string,
via: string,
requiredFor: readonly string[],
): BusinessEntityRelation {
return { entityKey, via, requiredFor };
}
function entity(
key: string,
name: string,
description: string,
options: {
searchTool?: string;
requiredPermissions: readonly string[];
fields: readonly BusinessEntityField[];
relations?: readonly BusinessEntityRelation[];
},
): BusinessEntity {
return {
key,
name,
description,
...(options.searchTool ? { searchTool: options.searchTool } : {}),
requiredPermissions: options.requiredPermissions,
fields: options.fields,
relations: options.relations ?? [],
};
}
/**
* 恭学系统业务上下文(代码内维护)。
*
* v1 覆盖三大闭环:学生教学、住宿计费、教室租赁。实体字段字典用于
* render_form 生成正确表单,工作流阶段/前置依赖/下一步建议用于引导
* Agent 感知业务流程。
*/
export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [
entity('student', '学生档案', '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', {
searchTool: 'search_students',
requiredPermissions: ['student:view'],
fields: [
field('name', '姓名', 'string', { required: true }),
field('studentNo', '学号', 'string'),
field('phone', '手机号', 'string'),
field('gender', '性别', 'enum', { enumFrom: 'student.gender' }),
field('idNumber', '身份证号', 'string'),
],
relations: [
relation('class', 'class_student', ['class']),
relation('occupancy', 'occupancy', ['checkin']),
relation('bill', 'bill', ['bill']),
relation('exam', 'exam_score', ['exam']),
],
}),
entity('class', '班级', '班级档案与在读分班关系,排课、考勤、考试依赖班级。', {
searchTool: 'search_classes',
requiredPermissions: ['class:view'],
fields: [
field('name', '班级名称', 'string', { required: true }),
field('grade', '年级', 'string'),
field('headTeacher', '班主任', 'string'),
],
relations: [
relation('student', 'class_student', ['class']),
relation('schedule', 'class_schedule', ['schedule']),
],
}),
entity('schedule', '排课/日程', '班级与教室的课程安排,考勤和教室日程依赖排课。', {
searchTool: 'search_schedules',
requiredPermissions: ['schedule:view'],
fields: [
field('classId', '班级', 'number'),
field('classroomId', '教室', 'number'),
field('weekDay', '星期', 'number'),
field('startTime', '开始时间', 'string'),
field('endTime', '结束时间', 'string'),
],
relations: [
relation('class', 'class_schedule', ['schedule']),
relation('classroom', 'class_schedule', ['schedule']),
],
}),
entity('attendance', '考勤', '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', {
searchTool: 'get_attendance_summary',
requiredPermissions: ['attendance:view'],
fields: [
field('studentId', '学生', 'number', { required: true }),
field('date', '日期', 'date', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'attendance.status' }),
],
relations: [
relation('class', 'class_schedule', ['attendance']),
relation('schedule', 'class_schedule', ['attendance']),
],
}),
entity('exam', '考试/成绩', '考试安排与成绩记录,依赖班级与学生档案。', {
searchTool: 'search_exams',
requiredPermissions: ['exam:view'],
fields: [
field('name', '考试名称', 'string', { required: true }),
field('date', '考试日期', 'date'),
field('subject', '科目', 'string'),
],
relations: [
relation('class', 'exam_score', ['exam']),
relation('student', 'exam_score', ['exam']),
],
}),
entity('room', '宿舍档案', '宿舍/床位基础档案,入住登记的前置数据。', {
searchTool: 'search_rooms',
requiredPermissions: ['room:view'],
fields: [
field('roomNumber', '宿舍号', 'string', { required: true }),
field('building', '楼栋', 'string'),
field('floor', '楼层', 'number'),
field('capacity', '容量', 'number', { required: true }),
field('roomType', '房型', 'string'),
field('monthlyRate', '月租金', 'number'),
],
relations: [relation('occupancy', 'occupancy', ['checkin'])],
}),
entity('occupancy', '入住记录', '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', {
searchTool: 'get_room_occupancy_summary',
requiredPermissions: ['occupancy:view'],
fields: [
field('studentId', '学生', 'number', { required: true }),
field('roomId', '宿舍', 'number', { required: true }),
field('checkInDate', '入住日期', 'date', { required: true }),
field('billingStartDate', '计费开始日期', 'date', { required: true }),
field('stayType', '住宿类型', 'enum', { enumFrom: 'occupancy.stayType' }),
],
relations: [
relation('student', 'occupancy', ['checkin']),
relation('room', 'occupancy', ['checkin']),
relation('bill', 'bill', ['bill']),
],
}),
entity('expense', '费用', '公共费用与个人费用,是生成账单的基础。', {
searchTool: 'search_expenses',
requiredPermissions: ['expense:view'],
fields: [
field('type', '费用类型', 'string', { required: true }),
field('amount', '金额', 'number', { required: true }),
field('periodStart', '费用开始日期', 'date'),
field('periodEnd', '费用结束日期', 'date'),
],
relations: [
relation('occupancy', 'expense', ['expense']),
relation('bill', 'bill_item', ['bill']),
],
}),
entity('bill', '账单', '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', {
searchTool: 'search_bills',
requiredPermissions: ['bill:view'],
fields: [
field('studentId', '学生', 'number', { required: true }),
field('periodStart', '账期开始', 'date', { required: true }),
field('periodEnd', '账期结束', 'date', { required: true }),
field('totalAmount', '总金额', 'number', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'bill.status' }),
],
relations: [
relation('student', 'bill', ['bill']),
relation('occupancy', 'bill', ['bill']),
relation('deposit', 'deposit', ['deposit']),
],
}),
entity('deposit', '押金', '押金收取与退还记录,通常在账单确认后处理。', {
searchTool: 'search_deposits',
requiredPermissions: ['deposit:view'],
fields: [
field('studentId', '学生', 'number', { required: true }),
field('amount', '金额', 'number', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'deposit.status' }),
],
relations: [
relation('student', 'deposit', ['deposit']),
relation('bill', 'deposit', ['deposit']),
],
}),
entity('classroom', '教室档案', '教室基础档案,租赁与教室日程的前置数据。', {
searchTool: 'search_classrooms',
requiredPermissions: ['classroom:view'],
fields: [
field('name', '教室名称', 'string', { required: true }),
field('building', '楼栋', 'string'),
field('capacity', '容量', 'number'),
],
relations: [
relation('rental', 'classroom_rental', ['rental']),
relation('schedule', 'class_schedule', ['schedule']),
],
}),
entity('organization', '组织/校区', '校区与组织归属,租赁双方与档案归属依赖组织。', {
requiredPermissions: ['organization:view'],
fields: [
field('name', '名称', 'string', { required: true }),
field('code', '编码', 'string'),
field('isHost', '是否本部', 'boolean'),
],
relations: [
relation('student', 'organization', ['profile']),
relation('rental', 'classroom_rental', ['rental']),
],
}),
entity('rental', '教室租赁', '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', {
searchTool: 'search_classroom_rentals',
requiredPermissions: ['rental:view'],
fields: [
field('classroomId', '教室', 'number', { required: true }),
field('lesseeOrganizationId', '承租方', 'number'),
field('startDate', '开始日期', 'date', { required: true }),
field('endDate', '结束日期', 'date', { required: true }),
field('dailyRate', '日租金', 'number'),
field('contractPath', '合同文件', 'string'),
],
relations: [
relation('classroom', 'classroom_rental', ['rental']),
relation('organization', 'classroom_rental', ['rental']),
relation('schedule', 'class_schedule', ['schedule']),
],
}),
];
export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [
{
key: 'student_teaching',
name: '学生教学闭环',
description: '学生档案 → 分班 → 排课 → 考勤 → 考试/成绩。',
requiredPermissions: ['student:view'],
stages: [
{ key: 'profile', label: '学生档案', entities: ['student'] },
{ key: 'class', label: '分班', entities: ['class', 'student'] },
{ key: 'schedule', label: '排课', entities: ['schedule', 'class'] },
{ key: 'attendance', label: '考勤', entities: ['attendance', 'schedule', 'class'] },
{ key: 'exam', label: '考试/成绩', entities: ['exam', 'class', 'student'] },
],
prerequisites: {
class: ['profile'],
schedule: ['class'],
attendance: ['schedule'],
exam: ['class'],
},
nextSteps: [
{ key: 'after_profile', label: '学生档案完成 → 建议分班', after: ['profile'] },
{ key: 'after_class', label: '分班完成 → 建议排课', after: ['class'] },
{ key: 'after_attendance', label: '考勤稳定 → 建议记录考试成绩', after: ['attendance'] },
],
},
{
key: 'dormitory_billing',
name: '住宿计费闭环',
description: '学生/宿舍档案 → 入住 → 费用录入 → 生成账单 → 押金。',
requiredPermissions: ['occupancy:view'],
stages: [
{ key: 'profile', label: '学生档案', entities: ['student'] },
{ key: 'room', label: '宿舍档案', entities: ['room'] },
{ key: 'checkin', label: '入住/换宿/退宿', entities: ['occupancy', 'student', 'room'] },
{ key: 'expense', label: '费用录入', entities: ['expense', 'occupancy'] },
{ key: 'bill', label: '生成账单', entities: ['bill', 'occupancy', 'expense'] },
{ key: 'deposit', label: '押金', entities: ['deposit', 'bill', 'student'] },
],
prerequisites: {
checkin: ['profile', 'room'],
expense: ['checkin'],
bill: ['checkin', 'expense'],
deposit: ['bill'],
},
nextSteps: [
{ key: 'after_checkin', label: '入住完成 → 建议录入本月公共/个人费用', after: ['checkin'] },
{ key: 'after_expense', label: '费用录入完成 → 建议生成并确认账单', after: ['expense'] },
{ key: 'after_bill', label: '账单确认 → 建议标记已付并处理押金', after: ['bill'] },
],
},
{
key: 'classroom_rental',
name: '教室租赁闭环',
description: '教室/组织档案 → 租赁订单 → 合同 → 教室日程。',
requiredPermissions: ['rental:view'],
stages: [
{ key: 'classroom', label: '教室档案', entities: ['classroom'] },
{ key: 'organization', label: '组织/校区', entities: ['organization'] },
{ key: 'rental', label: '租赁订单', entities: ['rental', 'classroom', 'organization'] },
{ key: 'contract', label: '合同', entities: ['rental'] },
{ key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] },
],
prerequisites: {
rental: ['classroom', 'organization'],
contract: ['rental'],
schedule: ['rental'],
},
nextSteps: [
{ key: 'after_rental', label: '租赁订单生成 → 建议补充合同', after: ['rental'] },
{ key: 'after_contract', label: '合同归档 → 建议排定教室日程', after: ['contract'] },
],
},
];
export const BUSINESS_WORKFLOW_KEYS: readonly string[] = BUSINESS_WORKFLOWS.map(
(workflow) => workflow.key,
);

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from '@jest/globals';
import { BusinessContextService } from './business-context.service';
function principal(permissions: string[] = [], isSuperAdmin = false) {
return { permissions, isSuperAdmin };
}
describe('BusinessContextService', () => {
it('registry 中所有工作流/实体/权限引用都有效', () => {
const service = new BusinessContextService();
expect(service.validateRegistry()).toEqual([]);
expect(() => service.assertValidRegistry()).not.toThrow();
});
it('超级管理员可查看全部工作流与实体', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(principal([], true));
expect(result.workflows.length).toBeGreaterThanOrEqual(3);
expect(result.entities.length).toBeGreaterThanOrEqual(10);
expect(result.workflows.map((item) => item.key)).toEqual(
expect.arrayContaining(['student_teaching', 'dormitory_billing', 'classroom_rental']),
);
});
it('按权限过滤实体,且只暴露有权限的工作流', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(principal(['student:view', 'class:view']));
const entityKeys = result.entities.map((item) => item.key);
expect(entityKeys).toContain('student');
expect(entityKeys).toContain('class');
expect(entityKeys).not.toContain('bill');
expect(result.workflows.map((item) => item.key)).toEqual(['student_teaching']);
});
it('workflowKey 聚焦时只返回对应工作流与实体', () => {
const service = new BusinessContextService();
const result = service.getBusinessContext(
principal(['room:view', 'occupancy:view', 'expense:view', 'bill:view', 'student:view'], false),
'dormitory_billing',
);
expect(result.workflows.map((item) => item.key)).toEqual(['dormitory_billing']);
expect(result.entities.map((item) => item.key)).toEqual(
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'student']),
);
});
it('未知或不可见 workflowKey 返回空结果', () => {
const service = new BusinessContextService();
expect(service.getBusinessContext(principal([], false), 'unknown_loop').workflows).toEqual([]);
expect(
service.getBusinessContext(principal(['student:view'], false), 'dormitory_billing').workflows,
).toEqual([]);
});
it('getEntitySchema 返回字段字典与关系,未知/无权限实体返回 null', () => {
const service = new BusinessContextService();
const student = service.getEntitySchema(principal(['student:view']), 'student');
expect(student?.fields.map((field) => field.key)).toEqual(
expect.arrayContaining(['name', 'phone', 'gender', 'studentNo']),
);
expect(student?.relations.some((relation) => relation.entityKey === 'class')).toBe(true);
expect(service.getEntitySchema(principal(['student:view']), 'bill')).toBeNull();
expect(service.getEntitySchema(principal(['student:view']), 'unknown')).toBeNull();
});
it('suggestNextSteps 只返回已完成阶段对应的下一步建议', () => {
const service = new BusinessContextService();
const steps = service.suggestNextSteps(principal([], true), ['profile', 'checkin']);
const labels = steps.map((item) => item.label);
expect(labels).toEqual(
expect.arrayContaining([expect.stringContaining('费用')]),
);
expect(labels.some((item) => item.includes('账单'))).toBe(false);
expect(service.suggestNextSteps(principal([], true), ['profile']).some((item) => item.after.includes('checkin'))).toBe(false);
const afterExpense = service.suggestNextSteps(principal([], true), [
'profile',
'room',
'checkin',
'expense',
]);
expect(afterExpense.map((item) => item.label)).toEqual(
expect.arrayContaining([expect.stringContaining('账单')]),
);
});
});

View File

@@ -0,0 +1,180 @@
import { Injectable } from '@nestjs/common';
import {
BUSINESS_ENTITIES,
BUSINESS_WORKFLOWS,
} from './business-context.registry';
import type {
BusinessContextPrincipal,
BusinessContextResult,
BusinessEntity,
BusinessWorkflow,
BusinessWorkflowNextStep,
} from './business-context.types';
function hasPermission(
principal: BusinessContextPrincipal,
requiredPermissions: readonly string[],
): boolean {
if (principal.isSuperAdmin) return true;
return requiredPermissions.some((permission) => principal.permissions.includes(permission));
}
/**
* 根据已完成阶段返回后续建议(按权限过滤),供 A2UI 提交回灌等场景直接使用。
*/
export function suggestNextStepsForPrincipal(
principal: BusinessContextPrincipal,
completedStageKeys: readonly string[],
): BusinessWorkflowNextStep[] {
const completed = new Set(completedStageKeys);
const steps: BusinessWorkflowNextStep[] = [];
for (const workflow of BUSINESS_WORKFLOWS) {
if (!hasPermission(principal, workflow.requiredPermissions)) continue;
for (const step of workflow.nextSteps) {
if (!step.after.every((stageKey) => completed.has(stageKey))) continue;
if (step.permission && !hasPermission(principal, [step.permission])) continue;
steps.push(step);
}
}
return steps;
}
/**
* 业务上下文服务:把代码内维护的实体字典与工作流元数据按权限暴露给
* Agent纯逻辑不访问数据库
*/
@Injectable()
export class BusinessContextService {
/**
* 校验注册表引用完整性,返回所有问题;空数组表示合法。
*/
validateRegistry(): string[] {
const problems: string[] = [];
const entityKeys = new Set(BUSINESS_ENTITIES.map((entity) => entity.key));
const stageKeys = new Set(
BUSINESS_WORKFLOWS.flatMap((workflow) => workflow.stages.map((stage) => stage.key)),
);
const duplicateEntities = BUSINESS_ENTITIES
.map((entity) => entity.key)
.filter((key, index, all) => all.indexOf(key) !== index);
if (duplicateEntities.length) {
problems.push(`实体 key 重复: ${duplicateEntities.join(', ')}`);
}
for (const entity of BUSINESS_ENTITIES) {
if (!entity.name || !entity.description) {
problems.push(`实体 ${entity.key} 缺少 name/description`);
}
if (entity.requiredPermissions.length === 0) {
problems.push(`实体 ${entity.key} 缺少权限点`);
}
const fieldKeys = new Set<string>();
for (const field of entity.fields) {
if (fieldKeys.has(field.key)) {
problems.push(`实体 ${entity.key} 字段重复: ${field.key}`);
}
fieldKeys.add(field.key);
if (field.type === 'enum' && !field.enumFrom && !field.options) {
problems.push(`实体 ${entity.key} 枚举字段 ${field.key} 缺少 enumFrom/options`);
}
}
for (const relation of entity.relations) {
if (!entityKeys.has(relation.entityKey)) {
problems.push(`实体 ${entity.key} 关系引用未知实体: ${relation.entityKey}`);
}
for (const stageKey of relation.requiredFor) {
if (!stageKeys.has(stageKey)) {
problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`);
}
}
}
}
for (const workflow of BUSINESS_WORKFLOWS) {
if (workflow.requiredPermissions.length === 0) {
problems.push(`工作流 ${workflow.key} 缺少权限点`);
}
const workflowStageKeys = new Set(workflow.stages.map((stage) => stage.key));
if (workflowStageKeys.size !== workflow.stages.length) {
problems.push(`工作流 ${workflow.key} 存在重复阶段`);
}
for (const stage of workflow.stages) {
for (const entityKey of stage.entities) {
if (!entityKeys.has(entityKey)) {
problems.push(`工作流 ${workflow.key} 阶段 ${stage.key} 引用未知实体: ${entityKey}`);
}
}
}
for (const [stageKey, prereqs] of Object.entries(workflow.prerequisites)) {
if (!workflowStageKeys.has(stageKey)) {
problems.push(`工作流 ${workflow.key} prerequisites 引用未知阶段: ${stageKey}`);
}
for (const prereq of prereqs) {
if (!workflowStageKeys.has(prereq)) {
problems.push(`工作流 ${workflow.key} 阶段 ${stageKey} 前置引用未知阶段: ${prereq}`);
}
}
}
for (const step of workflow.nextSteps) {
for (const afterKey of step.after) {
if (!workflowStageKeys.has(afterKey)) {
problems.push(`工作流 ${workflow.key} 下一步 ${step.key} 引用未知阶段: ${afterKey}`);
}
}
}
}
return [...new Set(problems)];
}
assertValidRegistry(): void {
const problems = this.validateRegistry();
if (problems.length > 0) {
throw new Error(`业务上下文注册表无效: ${problems.join('; ')}`);
}
}
/**
* 返回当前主体可见的工作流,以及这些工作流阶段引用的实体集合。
* 传 workflowKey 时只返回该工作流(不可见则返回空)。
*/
getBusinessContext(
principal: BusinessContextPrincipal,
workflowKey?: string,
): BusinessContextResult {
let workflows = BUSINESS_WORKFLOWS.filter((workflow) =>
hasPermission(principal, workflow.requiredPermissions),
);
if (workflowKey) {
workflows = workflows.filter((workflow) => workflow.key === workflowKey);
}
const entityKeys = new Set(
workflows.flatMap((workflow) => workflow.stages.flatMap((stage) => stage.entities)),
);
const entities = BUSINESS_ENTITIES.filter(
(entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions),
);
return { workflows, entities };
}
/** 返回指定实体的字段字典与关系;无权限或未知返回 null。 */
getEntitySchema(principal: BusinessContextPrincipal, entityKey: string): BusinessEntity | null {
const entity = BUSINESS_ENTITIES.find((item) => item.key === entityKey);
if (!entity || !hasPermission(principal, entity.requiredPermissions)) return null;
return entity;
}
/**
* 根据已完成阶段返回后续建议(按权限过滤)。
*/
suggestNextSteps(
principal: BusinessContextPrincipal,
completedStageKeys: readonly string[],
): BusinessWorkflowNextStep[] {
return suggestNextStepsForPrincipal(principal, completedStageKeys);
}
}
export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS };
export type { BusinessContextPrincipal, BusinessWorkflow };

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, jest } from '@jest/globals';
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
import type { BusinessContextService } from './business-context.service';
import { GetBusinessContextTool, GetEntitySchemaTool } from './get-business-context.tool';
import { GetPendingTasksTool } from './get-pending-tasks.tool';
function context(permissions: string[] = []): AgentToolContext {
const user: AuthenticatedUser = {
id: 7,
username: 'ops',
permissions,
isSuperAdmin: false,
roles: [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
describe('agent business context tools', () => {
it('get_business_context 校验 workflowKey 并透传主体与过滤条件', async () => {
const service = {
getBusinessContext: jest.fn().mockReturnValue({ workflows: [], entities: [] }),
} as unknown as BusinessContextService;
const tool = new GetBusinessContextTool(service);
expect(tool.requiredPermission).toBe('ai:chat:use');
expect(tool.validate({ workflowKey: 123 }).ok).toBe(false);
expect(tool.validate({ workflowKey: 'x'.repeat(51) }).ok).toBe(false);
expect(tool.validate({ debug: true }).ok).toBe(false);
const parsed = tool.validate({ workflowKey: 'dormitory_billing' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const ctx = context(['occupancy:view']);
await tool.execute(parsed.value, ctx);
expect(service.getBusinessContext).toHaveBeenCalledWith(
{ permissions: ['occupancy:view'], isSuperAdmin: false },
'dormitory_billing',
);
});
it('get_entity_schema 必须提供存在的 entityKey', async () => {
const service = {
getEntitySchema: jest.fn().mockReturnValue({ key: 'student', name: '学生档案' }),
} as unknown as BusinessContextService;
const tool = new GetEntitySchemaTool(service);
expect(tool.validate({}).ok).toBe(false);
expect(tool.validate({ entityKey: '' }).ok).toBe(false);
expect(tool.validate({ entityKey: 'student', extra: 1 }).ok).toBe(false);
const parsed = tool.validate({ entityKey: 'student' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
await tool.execute(parsed.value, context());
expect(service.getEntitySchema).toHaveBeenCalledWith(
{ permissions: [], isSuperAdmin: false },
'student',
);
});
it('get_pending_tasks 只接受已知工作流 key', async () => {
const service = {
getPendingTasks: jest.fn().mockResolvedValue([]),
};
const tool = new GetPendingTasksTool(service as never);
expect(tool.requiredPermission).toBe('ai:chat:use');
expect(tool.validate({ workflowKey: 'unknown_loop' }).ok).toBe(false);
expect(tool.validate({ workflowKey: 'dormitory_billing', limit: 1 }).ok).toBe(false);
const parsed = tool.validate({ workflowKey: 'dormitory_billing' });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
await tool.execute(parsed.value, context(['bill:view']));
expect(service.getPendingTasks).toHaveBeenCalledWith(expect.anything(), 'dormitory_billing');
});
});

View File

@@ -0,0 +1,66 @@
/** 调用方主体验证(来自可信的 AgentToolContext / AuthenticatedUser。 */
export interface BusinessContextPrincipal {
readonly permissions: readonly string[];
readonly isSuperAdmin: boolean;
}
export type BusinessEntityFieldType = 'string' | 'number' | 'boolean' | 'date' | 'enum';
export interface BusinessEntityField {
key: string;
label: string;
type: BusinessEntityFieldType;
required?: boolean;
/** 枚举值来源(如 gender、room.status供 render_form 生成 options。 */
enumFrom?: string;
options?: Array<{ label: string; value: string }>;
description?: string;
}
export interface BusinessEntityRelation {
entityKey: string;
via: string;
/** 需要该关系已建立的工作流阶段 key。 */
requiredFor: readonly string[];
}
export interface BusinessEntity {
key: string;
name: string;
description: string;
searchTool?: string;
requiredPermissions: readonly string[];
fields: readonly BusinessEntityField[];
relations: readonly BusinessEntityRelation[];
}
export interface BusinessWorkflowStage {
key: string;
label: string;
entities: readonly string[];
}
export interface BusinessWorkflowNextStep {
key: string;
label: string;
/** 满足这些阶段完成后才建议该步骤。 */
after: readonly string[];
/** 可选权限点,未授权用户不返回该建议。 */
permission?: string;
}
export interface BusinessWorkflow {
key: string;
name: string;
description: string;
requiredPermissions: readonly string[];
stages: readonly BusinessWorkflowStage[];
/** stageKey -> 前置阶段 key 列表。 */
prerequisites: Readonly<Record<string, readonly string[]>>;
nextSteps: readonly BusinessWorkflowNextStep[];
}
export interface BusinessContextResult {
workflows: readonly BusinessWorkflow[];
entities: readonly BusinessEntity[];
}

View File

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types';
import { rejectUnknownKeys, optionalString } from '../agent-tools/tools/tool-input';
import { BusinessContextService } from './business-context.service';
interface GetBusinessContextInput {
workflowKey?: string;
}
/**
* 让 Agent 获取当前角色可见的业务流程、实体字典与依赖规则。
*/
@Injectable()
export class GetBusinessContextTool implements ToolDef<GetBusinessContextInput> {
readonly name = 'get_business_context';
readonly skillKey = 'assistant';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取当前账号可见的业务流程(学生教学/住宿计费/教室租赁)、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';
readonly inputSchema = {
type: 'object',
properties: {
workflowKey: {
type: 'string',
description: '可选聚焦某个闭环student_teaching / dormitory_billing / classroom_rental',
maxLength: 50,
},
},
additionalProperties: false,
};
constructor(private readonly service: BusinessContextService) {}
validate(input: Record<string, unknown>): ToolInputResult<GetBusinessContextInput> {
const invalid = rejectUnknownKeys(input, ['workflowKey']);
if (invalid) return invalid;
const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50);
if (!workflowKey.ok) return workflowKey;
return { ok: true, value: { workflowKey: workflowKey.value } };
}
async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
return this.service.getBusinessContext(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.workflowKey,
);
}
}
/**
* 让 Agent 获取指定业务实体的字段字典与关系,用于生成准确表单。
*/
@Injectable()
export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> {
readonly name = 'get_entity_schema';
readonly skillKey = 'assistant';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取指定业务实体student/class/room/occupancy/expense/bill/deposit/classroom/organization/rental 等的字段字典、枚举来源与关系render_form 前可按需调用以生成正确字段。';
readonly inputSchema = {
type: 'object',
properties: {
entityKey: { type: 'string', minLength: 1, maxLength: 50 },
},
required: ['entityKey'],
additionalProperties: false,
};
constructor(private readonly service: BusinessContextService) {}
validate(input: Record<string, unknown>): ToolInputResult<{ entityKey: string }> {
const invalid = rejectUnknownKeys(input, ['entityKey']);
if (invalid) return invalid;
if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) {
return { ok: false, error: 'entityKey 必须是字符串' };
}
const entityKey = input.entityKey.trim();
if (entityKey.length > 50) {
return { ok: false, error: 'entityKey 长度不能超过 50' };
}
return { ok: true, value: { entityKey } };
}
async execute(input: { entityKey: string }, context: AgentToolContext): Promise<unknown> {
return this.service.getEntitySchema(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.entityKey,
);
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types';
import { rejectUnknownKeys } from '../agent-tools/tools/tool-input';
import { BUSINESS_WORKFLOW_KEYS } from './business-context.registry';
import { PendingTasksService } from './pending-tasks.service';
interface GetPendingTasksInput {
workflowKey?: string;
}
/**
* 让 Agent 查询业务完成度(未分班、未入住、未出账单、临期租赁等),
* 用于核实前置数据与给出有数据支撑的下一步建议。
*/
@Injectable()
export class GetPendingTasksTool implements ToolDef<GetPendingTasksInput> {
readonly name = 'get_pending_tasks';
readonly skillKey = 'overview';
readonly requiredPermission = 'ai:chat:use';
readonly description =
'获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等)。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。';
readonly inputSchema = {
type: 'object',
properties: {
workflowKey: {
type: 'string',
description: '可选聚焦某个闭环student_teaching / dormitory_billing / classroom_rental',
enum: [...BUSINESS_WORKFLOW_KEYS],
},
},
additionalProperties: false,
};
constructor(private readonly service: PendingTasksService) {}
validate(input: Record<string, unknown>): ToolInputResult<GetPendingTasksInput> {
const invalid = rejectUnknownKeys(input, ['workflowKey']);
if (invalid) return invalid;
if (input.workflowKey === undefined) return { ok: true, value: {} };
if (
typeof input.workflowKey !== 'string' ||
!(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey)
) {
return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
}
return { ok: true, value: { workflowKey: input.workflowKey } };
}
execute(input: GetPendingTasksInput, context: AgentToolContext): Promise<unknown> {
return this.service.getPendingTasks(context, input.workflowKey);
}
}

View File

@@ -0,0 +1,12 @@
export { BusinessContextService } from './business-context.service';
export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry';
export type {
BusinessContextPrincipal,
BusinessContextResult,
BusinessEntity,
BusinessEntityField,
BusinessEntityRelation,
BusinessWorkflow,
BusinessWorkflowNextStep,
BusinessWorkflowStage,
} from './business-context.types';

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, jest } from '@jest/globals';
import type { DataSource } from 'typeorm';
import { AgentToolContextFactory, type AgentToolContext } from '../agent-tools/agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
import type { StudentAccessScopeFactory } from '../students/student-access-scope.factory';
import { PendingTasksService } from './pending-tasks.service';
function context(permissions: string[], isSuperAdmin = false): AgentToolContext {
const user: AuthenticatedUser = {
id: 7,
username: 'ops',
permissions,
isSuperAdmin,
roles: [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
function createService(
scopeType: 'manageAll' | 'teacher',
queryMock: jest.Mock<Promise<Array<Record<string, unknown>>>>,
) {
const dataSource = { query: queryMock } as unknown as DataSource;
const scopeFactory = {
buildScope: jest.fn().mockReturnValue(
scopeType === 'manageAll'
? { type: 'manageAll' }
: { type: 'teacher', userId: 7 },
),
} as unknown as StudentAccessScopeFactory;
return new PendingTasksService(dataSource, scopeFactory);
}
const ROW = (cnt: unknown) => [{ cnt }] as Record<string, unknown>[];
describe('PendingTasksService', () => {
it('manageAll 权限下返回全部待办计数', async () => {
const query = jest.fn().mockImplementation(async (sql: string) => {
if (sql.includes('NOT EXISTS') && sql.includes('class_student')) return ROW('3');
if (sql.includes('LEFT JOIN occupancies o')) return ROW('5');
if (sql.includes('FROM occupancies o')) return ROW('8');
if (sql.includes('classroom_rentals r') && sql.includes('contract_path IS NULL')) return ROW('2');
if (sql.includes('classroom_rentals r') && sql.includes('end_date BETWEEN')) return ROW('4');
return ROW('0');
});
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(
context([
'student:view',
'class:view',
'occupancy:view',
'bill:view',
'rental:view',
]),
);
const byKey = Object.fromEntries(tasks.map((task) => [task.key, task]));
expect(byKey.students_without_class?.count).toBe(3);
expect(byKey.students_without_checkin?.count).toBe(5);
expect(byKey.occupancies_without_bill?.count).toBe(8);
expect(byKey.rentals_without_contract?.count).toBe(2);
expect(byKey.rentals_ending_soon?.count).toBe(4);
expect(byKey.students_without_class?.restricted).toBeUndefined();
});
it('teacher 范围只统计本人班级学生,且未分班任务标记 restricted', async () => {
const query = jest.fn().mockImplementation(async (sql: string, params: unknown[]) => {
expect(sql).toContain('cs.class_id IN');
expect(params).toEqual([7]);
if (sql.includes('LEFT JOIN occupancies o')) return ROW('2');
if (sql.includes('FROM occupancies o')) return ROW('1');
return ROW('0');
});
const service = createService('teacher', query);
const tasks = await service.getPendingTasks(
context(['student:view', 'occupancy:view', 'bill:view']),
);
const byKey = Object.fromEntries(tasks.map((task) => [task.key, task]));
expect(byKey.students_without_class).toMatchObject({ count: null, restricted: true });
expect(byKey.students_without_checkin?.count).toBe(2);
expect(byKey.occupancies_without_bill?.count).toBe(1);
expect(byKey.rentals_without_contract).toBeUndefined();
});
it('workflowKey 只返回该闭环的待办', async () => {
const query = jest.fn().mockResolvedValue(ROW('0'));
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(
context([], true),
'classroom_rental',
);
expect(tasks.map((task) => task.key)).toEqual([
'rentals_without_contract',
'rentals_ending_soon',
]);
});
it('缺少权限的任务不返回', async () => {
const query = jest.fn().mockResolvedValue(ROW('0'));
const service = createService('manageAll', query);
const tasks = await service.getPendingTasks(context(['student:view']));
expect(tasks.map((task) => task.key)).toEqual(['students_without_class']);
});
});

View File

@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
import type { StudentAccessScope } from '../students/student-access-scope';
import { StudentAccessScopeFactory } from '../students/student-access-scope.factory';
export interface PendingTask {
key: string;
label: string;
entity: string;
permission: string;
workflowKeys: string[];
count: number | null;
restricted?: boolean;
detail?: string;
}
interface TaskDefinition {
key: string;
label: string;
entity: string;
permission: string;
workflowKeys: string[];
teacherRestricted?: boolean;
sql: (scope: StudentAccessScope) => { sql: string; params: unknown[] };
}
function can(context: AgentToolContext, permission: string): boolean {
return context.isSuperAdmin || context.permissions.includes(permission);
}
function today(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function inDays(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const TASKS: readonly TaskDefinition[] = [
{
key: 'students_without_class',
label: '未分班学生',
entity: 'student',
permission: 'student:view',
workflowKeys: ['student_teaching'],
teacherRestricted: true,
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM students s
WHERE s.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM class_student cs
WHERE cs.student_id = s.id AND cs.status = 'active'
)
`,
params: [],
}),
},
{
key: 'students_without_checkin',
label: '已建档未入住学生',
entity: 'occupancy',
permission: 'occupancy:view',
workflowKeys: ['dormitory_billing'],
sql: (scope) => ({
sql: `
SELECT COUNT(DISTINCT s.id) AS cnt FROM students s
INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active'
LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active'
WHERE s.status = 'active'
${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''}
AND o.id IS NULL
`,
params: scope.type === 'teacher' ? [scope.userId] : [],
}),
},
{
key: 'occupancies_without_bill',
label: '在住但未生成账单',
entity: 'bill',
permission: 'bill:view',
workflowKeys: ['dormitory_billing'],
sql: (scope) => ({
sql: `
SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o
${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''}
LEFT JOIN bills b ON b.student_id = o.student_id
WHERE o.status = 'active'
${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''}
AND b.id IS NULL
`,
params: scope.type === 'teacher' ? [scope.userId] : [],
}),
},
{
key: 'rentals_without_contract',
label: '租赁未归档合同',
entity: 'rental',
permission: 'rental:view',
workflowKeys: ['classroom_rental'],
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM classroom_rentals r
WHERE r.status = 'active' AND r.contract_path IS NULL
`,
params: [],
}),
},
{
key: 'rentals_ending_soon',
label: '七天内到期租赁',
entity: 'rental',
permission: 'rental:view',
workflowKeys: ['classroom_rental'],
sql: () => ({
sql: `
SELECT COUNT(*) AS cnt FROM classroom_rentals r
WHERE r.status = 'active' AND r.end_date BETWEEN ? AND ?
`,
params: [today(), inDays(7)],
}),
},
];
/**
* 业务完成度查询:按权限与数据范围返回“待办”计数,供 Agent 在
* 写入/导入前核实前置数据、完成后给出有数据支撑的下一步建议。
*/
@Injectable()
export class PendingTasksService {
constructor(
private readonly dataSource: DataSource,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
async getPendingTasks(
context: AgentToolContext,
workflowKey?: string,
): Promise<PendingTask[]> {
const scope = this.scopeFactory.buildScope(context);
const tasks: PendingTask[] = [];
for (const definition of TASKS) {
if (!can(context, definition.permission)) continue;
if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue;
if (definition.teacherRestricted && scope.type === 'teacher') {
tasks.push({
key: definition.key,
label: definition.label,
entity: definition.entity,
permission: definition.permission,
workflowKeys: definition.workflowKeys,
count: null,
restricted: true,
detail: '当前角色数据范围不适合统计该待办,建议由教务/运营角色处理',
});
continue;
}
const { sql, params } = definition.sql(scope);
const rows = await this.dataSource.query(sql, params);
const count = Number((rows as Array<Record<string, unknown>>)[0]?.cnt ?? 0);
tasks.push({
key: definition.key,
label: definition.label,
entity: definition.entity,
permission: definition.permission,
workflowKeys: definition.workflowKeys,
count,
});
}
return tasks;
}
}

View File

@@ -4,8 +4,8 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
{
key: 'overview',
name: '经营总览',
description: '查看当前权限范围内的学生、班级和今日考勤概览。',
examples: ['今天整体运营情况怎么样?', '帮我汇总当前学生和班级数量'],
description: '查看当前权限范围内的学生、班级和今日考勤概览,以及业务待办(未分班、未入住、未出账单、临期租赁等)。',
examples: ['今天整体运营情况怎么样?', '有哪些业务待办需要处理?', '帮我汇总当前学生和班级数量'],
},
{
key: 'student',
@@ -22,20 +22,20 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
{
key: 'dormitory',
name: '宿舍管理',
description: '查询宿舍、入住数量和空余床位。',
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况'],
description: '查询宿舍、入住数量和空余床位,并按“学生/宿舍档案 → 入住 → 费用 → 账单 → 押金”闭环引导。',
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况', '我可以按 先学生、再入住、后账单 帮你完成'],
},
{
key: 'billing',
name: '账单查询',
description: '查询账单编号、账期、金额和状态。',
examples: ['查找本月未支付账单', '查询张同学最近的账单'],
description: '查询账单编号、账期、金额和状态,并在入住/费用完成后建议生成与确认账单。',
examples: ['查找本月未支付账单', '查询张同学最近的账单', '哪些在住学生还没生成账单?'],
},
{
key: 'classroom',
name: '教室与租用',
description: '查询教室信息、占用状态和租赁订单。',
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'],
description: '查询教室信息、占用状态和租赁订单,并按“教室/组织档案 → 租赁 → 合同 → 日程”闭环引导。',
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?', '哪些租赁还没归档合同?'],
},
{
key: 'sync',

View File

@@ -32,6 +32,10 @@ import { ExpensesModule } from '../expenses/expenses.module';
import { ClassroomsModule } from '../classrooms/classrooms.module';
import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module';
import { SyncModule } from '../sync/sync.module';
import { BusinessContextService } from '../agent-context/business-context.service';
import { PendingTasksService } from '../agent-context/pending-tasks.service';
import { GetBusinessContextTool, GetEntitySchemaTool } from '../agent-context/get-business-context.tool';
import { GetPendingTasksTool } from '../agent-context/get-pending-tasks.tool';
/**
* Agent Tools feature module.
@@ -66,6 +70,11 @@ import { SyncModule } from '../sync/sync.module';
providers: [
AgentToolRegistry,
AgentToolExecutor,
BusinessContextService,
PendingTasksService,
GetBusinessContextTool,
GetEntitySchemaTool,
GetPendingTasksTool,
SearchStudentsTool,
GetStudentBasicTool,
AgentBusinessScopeFactory,
@@ -107,9 +116,15 @@ export class AgentToolsModule implements OnModuleInit {
private readonly searchClassroomsTool: SearchClassroomsTool,
private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool,
private readonly getSyncStatusTool: GetSyncStatusTool,
private readonly businessContextTool: GetBusinessContextTool,
private readonly entitySchemaTool: GetEntitySchemaTool,
private readonly pendingTasksTool: GetPendingTasksTool,
) {}
onModuleInit(): void {
this.registry.register(this.businessContextTool);
this.registry.register(this.entitySchemaTool);
this.registry.register(this.pendingTasksTool);
this.registry.register(this.searchTool);
this.registry.register(this.getTool);
this.registry.register(this.searchClassesTool);

View File

@@ -0,0 +1,69 @@
import { describe, expect, it, jest } from '@jest/globals';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
function createService(overrides: Record<string, unknown> = {}) {
const repo = {
findOne: jest.fn(),
save: jest.fn(async (value) => value),
create: jest.fn((value) => value),
...overrides,
};
return { service: new A2uiSubmissionsService(repo as never), repo };
}
const submission = {
id: 1,
artifactId: 'form-1',
clientRequestId: '4d5c1c2a-1111-4222-8333-444455556666',
status: 'created',
resultJson: '{"ok":true}',
createdAt: new Date(),
};
describe('A2uiSubmissionsService', () => {
it('recordSubmission 首次提交创建记录并标记 created', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(null),
});
const result = await service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: submission.resultJson,
});
expect(result.created).toBe(true);
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
}),
);
});
it('recordSubmission 同一 clientRequestId 重复提交返回既有记录且不重复创建', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(submission),
});
const result = await service.recordSubmission({
artifactId: submission.artifactId,
clientRequestId: submission.clientRequestId,
status: 'created',
resultJson: '{"ok":false}',
});
expect(result.created).toBe(false);
expect(result.submission).toEqual(submission);
expect(repo.save).not.toHaveBeenCalled();
});
it('findSubmission 透传查询条件', async () => {
const { service, repo } = createService({
findOne: jest.fn().mockResolvedValue(submission),
});
await expect(
service.findSubmission(submission.artifactId, submission.clientRequestId),
).resolves.toEqual(submission);
expect(repo.findOne).toHaveBeenCalledWith({
where: { artifactId: submission.artifactId, clientRequestId: submission.clientRequestId },
});
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiA2uiSubmission } from './entities/ai-a2ui-submission.entity';
export interface RecordSubmissionInput {
artifactId: string;
clientRequestId: string;
status: string;
resultJson?: string | null;
}
/**
* A2UI 提交幂等服务。
*/
@Injectable()
export class A2uiSubmissionsService {
constructor(
@InjectRepository(AiA2uiSubmission)
private readonly submissions: Repository<AiA2uiSubmission>,
) {}
async recordSubmission(input: RecordSubmissionInput): Promise<{
created: boolean;
submission: AiA2uiSubmission;
}> {
const existing = await this.findSubmission(input.artifactId, input.clientRequestId);
if (existing) return { created: false, submission: existing };
const submission = await this.submissions.save(
this.submissions.create({
artifactId: input.artifactId,
clientRequestId: input.clientRequestId,
status: input.status,
resultJson: input.resultJson ?? null,
}),
);
return { created: true, submission };
}
async findSubmission(
artifactId: string,
clientRequestId: string,
): Promise<AiA2uiSubmission | null> {
return this.submissions.findOne({
where: { artifactId, clientRequestId },
});
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from '@jest/globals';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
describe('buildA2uiArtifact', () => {
it('构造统一 artifact 载荷id/type/status/messageId/conversationId/payload', () => {
const artifact = buildA2uiArtifact({
type: 'form',
id: 'form-1',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { title: '新增学生' },
});
expect(artifact).toEqual({
id: 'form-1',
type: 'form',
status: 'pending',
messageId: 12,
conversationId: 3,
payload: { title: '新增学生' },
});
});
it('未知类型被拒绝', () => {
expect(() =>
buildA2uiArtifact({
type: 'hacker' as never,
id: 'x',
status: 'pending',
messageId: 1,
conversationId: 1,
payload: {},
}),
).toThrow();
});
});

View File

@@ -0,0 +1,56 @@
export type A2uiArtifactType =
| 'form'
| 'review'
| 'chart'
| 'import_wizard';
export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
export interface A2uiArtifact<T = unknown> {
id: string;
type: A2uiArtifactType;
status: A2uiArtifactStatus;
messageId: number;
conversationId: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}
const A2UI_ARTIFACT_TYPES = new Set<A2uiArtifactType>([
'form',
'review',
'chart',
'import_wizard',
]);
/**
* 构造统一的 A2UI artifact 载荷,供 `ui.artifact` SSE 事件与前端归一化。
*/
export function buildA2uiArtifact<T>(input: {
type: A2uiArtifactType;
id: string;
status: A2uiArtifactStatus;
messageId: number;
conversationId: number;
payload: T;
createdAt?: string | null;
submittedAt?: string | null;
supersededBy?: string | null;
}): A2uiArtifact<T> {
if (!A2UI_ARTIFACT_TYPES.has(input.type)) {
throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`);
}
return {
id: input.id,
type: input.type,
status: input.status,
messageId: input.messageId,
conversationId: input.conversationId,
payload: input.payload,
...(input.createdAt !== undefined ? { createdAt: input.createdAt } : {}),
...(input.submittedAt !== undefined ? { submittedAt: input.submittedAt } : {}),
...(input.supersededBy !== undefined ? { supersededBy: input.supersededBy } : {}),
};
}

View File

@@ -20,6 +20,14 @@ describe('AiAttachmentService', () => {
expect(detectMimeType(buffer, declared)).toBe(expected);
});
it('detects CSV from the declared MIME type without a binary signature', () => {
const detectMimeType = (
service as unknown as { detectMimeType(buffer: Buffer, declared: string): string }
).detectMimeType.bind(service);
expect(detectMimeType(Buffer.from('姓名,学号\n张三,1\n'), 'text/csv')).toBe('text/csv');
expect(detectMimeType(Buffer.from('a,b\n1,2\n'), 'application/csv')).toBe('text/csv');
});
it('rejects more than five attachments before repository access', async () => {
await expect(service.requireReadyOwned(7, [1, 2, 3, 4, 5, 6])).rejects.toBeInstanceOf(
BadRequestException,
@@ -48,6 +56,8 @@ describe('AiAttachmentService', () => {
).assertFileExtension.bind(service);
expect(() => assertFileExtension('report.exe', 'application/pdf')).toThrow(BadRequestException);
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
expect(() => assertFileExtension('students.csv', 'text/csv')).not.toThrow();
expect(() => assertFileExtension('students.xlsx', 'text/csv')).toThrow(BadRequestException);
});
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
@@ -137,29 +147,4 @@ describe('AiAttachmentService', () => {
expect(text).toContain('13800138000');
});
it('extracts pptx text via OfficeCli', async () => {
const officeCli = {
view: jest.fn().mockResolvedValue({
success: true,
data: { elements: [{ text: '第一页标题' }, { text: '' }, { text: '正文内容' }] },
}),
};
const local = new AiAttachmentService(
repository as never,
new AiExcelReaderService(),
officeCli as never,
);
const extract = (
local as unknown as {
extractText(buffer: Buffer, mimeType: string): Promise<string | null>;
}
).extractText.bind(local);
const text = await extract(
Buffer.from('fake-pptx'),
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
);
expect(text).toContain('第一页标题');
expect(text).toContain('正文内容');
expect(officeCli.view).toHaveBeenCalled();
});
});

View File

@@ -8,21 +8,20 @@ import { createReadStream } from 'node:fs';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { PDFParse } from 'pdf-parse';
import { In, Repository } from 'typeorm';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { OfficeCliService } from './office-cli.service';
import { AiAttachment } from './entities';
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_EXTRACTED_CHARS = 48 * 1024;
const MAX_MODEL_IMAGE_BYTES = 20 * 1024 * 1024;
const ACCEPTED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
'text/csv',
'application/csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -43,7 +42,6 @@ export class AiAttachmentService {
@InjectRepository(AiAttachment)
private readonly attachments: Repository<AiAttachment>,
private readonly excelReader: AiExcelReaderService,
private readonly officeCli?: OfficeCliService,
) {}
async upload(userId: number, file: Express.Multer.File): Promise<AiAttachment> {
@@ -165,7 +163,7 @@ export class AiAttachmentService {
} else {
parts.push({
attachment,
text: attachment.extractedText?.slice(0, MAX_EXTRACTED_CHARS) || '',
text: attachment.extractedText || '',
});
}
}
@@ -214,10 +212,8 @@ export class AiAttachmentService {
const result = await mammoth.extractRawText({ buffer });
return this.normalizeExtractedText(result.value);
}
if (mimeType.includes('presentationml')) {
if (!this.officeCli) return null;
const text = await this.extractWithOfficeCli(buffer, mimeType);
return this.normalizeExtractedText(text);
if (mimeType.includes('csv')) {
return this.normalizeExtractedText(buffer.toString('utf8').replace(/^\uFEFF/, ''));
}
if (mimeType.includes('spreadsheetml')) {
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
@@ -225,57 +221,6 @@ export class AiAttachmentService {
return null;
}
private async extractWithOfficeCli(buffer: Buffer, mimeType: string): Promise<string> {
if (!this.officeCli) return '';
const extension = this.extensionForMime(mimeType);
const tempPath = join(tmpdir(), `${randomUUID()}.${extension}`);
try {
await writeFile(tempPath, buffer, { flag: 'wx' });
const result = await this.officeCli.view(tempPath, 'text');
if (!result.success || !result.data || typeof result.data !== 'object') return '';
const data = result.data as { sheets?: Array<{ name: string; rows: unknown[] }>; elements?: Array<{ text?: string }> };
if (Array.isArray(data.sheets)) {
return data.sheets
.map((sheet) => {
const lines: string[] = [];
for (const row of sheet.rows ?? []) {
if (!row || typeof row !== 'object' || !('cells' in row)) continue;
const cells = (row as { cells: Record<string, unknown> }).cells;
const placed = new Map<number, string>();
let maxColumn = -1;
for (const [key, value] of Object.entries(cells)) {
const columnIndex = this.officeColumnIndex(key.replace(/\d+/g, ''));
placed.set(columnIndex, String(value ?? ''));
maxColumn = Math.max(maxColumn, columnIndex);
}
if (maxColumn < 0) continue;
const line = Array.from({ length: maxColumn + 1 }, (_, index) => placed.get(index) ?? '').join('\t');
if (line.trim()) lines.push(line);
}
return `# ${sheet.name}\n${lines.join('\n')}`;
})
.join('\n');
}
if (Array.isArray(data.elements)) {
return data.elements
.map((element) => element.text ?? '')
.filter((line) => line.trim() !== '')
.join('\n');
}
return '';
} finally {
await unlink(tempPath).catch(() => undefined);
}
}
private officeColumnIndex(letters: string): number {
let index = 0;
for (const char of letters.toUpperCase()) {
index = index * 26 + (char.charCodeAt(0) - 64);
}
return index - 1;
}
/**
* Read the stored file content of an already-owned attachment so the AI
* chat agent can page through large workbooks on demand.
@@ -290,11 +235,12 @@ export class AiAttachmentService {
}
private normalizeExtractedText(value: string): string {
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim();
}
private assertDeclaredType(declared: string, detected: string): void {
if (!declared || declared === 'application/octet-stream') return;
if (detected.includes('csv') || declared.includes('csv')) return;
if (declared !== detected) throw new BadRequestException('附件类型与文件内容不一致');
}
@@ -305,6 +251,8 @@ export class AiAttachmentService {
'image/png': ['png'],
'image/webp': ['webp'],
'application/pdf': ['pdf'],
'text/csv': ['csv'],
'application/csv': ['csv'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
@@ -338,6 +286,7 @@ export class AiAttachmentService {
) {
return declaredMimeType;
}
if (/csv/i.test(declaredMimeType)) return 'text/csv';
return 'application/octet-stream';
}
@@ -365,6 +314,8 @@ export class AiAttachmentService {
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
'text/csv': 'csv',
'application/csv': 'csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',

View File

@@ -1,11 +1,8 @@
export const MAX_HISTORY_MESSAGES = 30;
export const MAX_CONTEXT_CHARS = 64 * 1024;
export const MAX_TOOL_CALLS_PER_ROUND = 50;
export const MAX_TOOL_ROUNDS = 90;
export const MAX_SUMMARY_CHARS = 2000;
export const MAX_GENERATED_CHARS = 256 * 1024;
export const MAX_ATTACHMENT_TEXT_CHARS = 20000;
export const MAX_FOCUS_CONTENT_CHARS = 40000;
export const DEFAULT_TITLE = '新对话';
const CELL_VALUE_ANY_OF = [
@@ -21,7 +18,7 @@ export const A2UI_TOOL_SCHEMAS = [
function: {
name: 'start_import_wizard',
description:
'生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages业务类型 + 工作表名),系统直接解析文件、自动识别列映射并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。',
'生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages业务类型 + 工作表名),并把用户确认的 mapping/organization/updateExisting/duplicatePolicy/skipUnmatched 一并传入。系统直接解析文件、应用确认策略并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。',
parameters: {
type: 'object',
properties: {
@@ -46,6 +43,34 @@ export const A2UI_TOOL_SCHEMAS = [
additionalProperties: false,
},
},
mapping: {
type: 'object',
description:
'列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom。用户确认后传入未确认时省略系统自动识别。',
additionalProperties: {
type: 'object',
description: '字段名 -> 工作表表头',
additionalProperties: { type: 'string', maxLength: 200 },
},
},
organization: {
type: 'string',
description: '确认后的校区名称(出现未知校区时由用户确认)',
maxLength: 100,
},
updateExisting: {
type: 'boolean',
description: '是否更新已匹配的现有记录;默认 truefalse 时已匹配行跳过',
},
duplicatePolicy: {
type: 'string',
description: '文件内重复行策略error 标记错误 / skip 跳过重复行;默认 error',
enum: ['error', 'skip'],
},
skipUnmatched: {
type: 'boolean',
description: '关系表(入住/换宿)找不到学生或宿舍时是否跳过该行;默认 false',
},
},
required: ['attachmentId', 'stages'],
additionalProperties: false,
@@ -57,7 +82,7 @@ export const A2UI_TOOL_SCHEMAS = [
function: {
name: 'render_form',
description:
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。',
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。需要准确字段/枚举时,可先调用 get_entity_schema 获取实体字段字典。',
parameters: {
type: 'object',
properties: {
@@ -100,70 +125,6 @@ export const A2UI_TOOL_SCHEMAS = [
},
},
},
{
type: 'function' as const,
function: {
name: 'render_review',
description:
'生成一张“批量导入工作流预览卡”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 后系统直接解析文件生成行数据推荐避免抄录错误sections 只需给出分表、表名和列映射;无附件时才手工提供 rows。用户确认后系统才会入库。每个回答回合只能调用一次且只生成一张预览卡需要导入的多个分表最多 20 个)必须合并到同一次调用的 sections 里,一次全部给出;同一业务类型可有多张 sheet每张 sheet 分配唯一 key 并填写正确的 type生成成功后直接提示用户审阅可逐表确认、整组确认或一次全部确认不要重复调用本工具。',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: '预览标题≤50字', maxLength: 50 },
summary: { type: 'string', description: '预览说明≤500字', maxLength: 500 },
attachmentId: {
type: 'integer',
description: '上传的 Excel 附件 ID。传入后系统直接从文件读取全部行数据无需也不要在 rows 里抄录数据。',
},
sections: {
type: 'array',
description: '分表预览1-20个。每张 sheet 的 key 必须是唯一实例 ID仅字母数字下划线≤50type 为业务类型。',
minItems: 1,
maxItems: 20,
items: {
type: 'object',
properties: {
key: { type: 'string', description: '唯一实例 ID如 checkins_girls_4、students_building_2仅字母数字下划线且 ≤50 字符', pattern: '^[a-zA-Z0-9_]{1,50}$' },
type: { type: 'string', description: '业务类型students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录', enum: ['students', 'rooms', 'transfers', 'checkins'] },
title: { type: 'string', description: '分表标题≤50字', maxLength: 50 },
kind: { type: 'string', enum: ['table'], description: '固定为 table' },
sheet: { type: 'string', description: '工作表名称(与 Excel 中的 sheet 名一致);省略时使用第一个工作表' },
headerRow: { type: 'integer', description: '表头所在行(从 1 开始),默认 1' },
columns: {
type: 'array',
description: '表格列定义1-30个。省略 sourceHeader 时系统按表头文字自动识别;给出 sourceHeader 可指定该列在工作表中的原始表头。',
items: {
type: 'object',
properties: {
key: { type: 'string', description: '列标识,仅字母数字下划线', pattern: '^[a-zA-Z0-9_]{1,50}$' },
title: { type: 'string', description: '列中文标题≤50字', maxLength: 50 },
sourceHeader: { type: 'string', description: '工作表中对应的原始表头文字(如 姓名/手机号)', maxLength: 50 },
},
required: ['key', 'title'],
additionalProperties: false,
},
},
rows: {
type: 'array',
description: '行数据≤500行。建议键名学生 name/phone/studentNo/gender/organization宿舍 roomNumber/capacity/building/floor/roomType换宿 studentNo 或 studentPhone、oldRoom、newRoom、transferDateYYYY-MM-DD入住记录 name/phone 或 studentNo、roomNumber、checkInDateYYYY-MM-DD。服务端兼容常见别名。',
items: {
type: 'object',
description: '单元格值仅允许字符串、数字、布尔或 null',
additionalProperties: { anyOf: CELL_VALUE_ANY_OF },
},
},
issues: { type: 'array', description: '解析中发现的问题≤50条', items: { type: 'string' } },
},
required: ['key', 'type', 'title', 'kind', 'columns', 'rows'],
additionalProperties: false,
},
},
},
required: ['title', 'sections'],
additionalProperties: false,
},
},
},
{
type: 'function' as const,
function: {
@@ -214,13 +175,19 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
当用户需要录入或修改业务数据时,先调用 render_form 生成确认表单,提示用户填写并提交;只有在用户通过表单提交确认后,才能执行写操作工具(如 create_student、update_students
新增学生示例render_form 的 fields 使用 name/phone/gender/studentNo。
修改学生示例:批量修改姓名/档案时render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students每条更新必须带学生 id。
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,先调用 start_import_wizard 生成“导入向导”:必须传入 attachmentId上传附件的 ID和 stages声明业务类型 stepKeystudents 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全;生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。每个回答回合最多调用一次 start_import_wizard。
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。
2. 用户确认后调用 start_import_wizard必须传入 attachmentId 和 stages业务类型 stepKeystudents 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名并把确认结果一并传入mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
3. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。
每个回答回合最多调用一次 start_import_wizard导入完成后由你给出下一步建议不要自动执行后续写操作。
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗columns+rows 表格数据)。
上传的 Office 附件Excel/Word/PPT可用 office_analyze 查看结构stats/outline确认表名与表头批量导入前如不确定列名可用 get/query 只读少量单元格核对,不要读取整表
上传的 Office 附件上传时系统已自动提取附件文本并随消息提供Excel 为“工作表名 + tab 分隔行”的文本Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入时直接调用 start_import_wizard系统会从文件解析表头与行数据
业务工作流引导(重要):
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单押金等结算
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么,再生成预览卡;多业务分表合并到同一张预览卡,并按依赖顺序执行
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单押金)、教室租赁(教室/组织→租赁→合同→日程)
- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema
- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库
- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;确认后调用 start_import_wizard 生成导入向导,按依赖顺序执行。
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;

View File

@@ -24,7 +24,7 @@ import type { AuthenticatedUser } from '../authorization';
import { AiAttachmentService } from './ai-attachment.service';
import { AiChatService } from './ai-chat.service';
import type { AiSseEventName } from './ai-chat.types';
import type { AiReviewSection, AiReviewSectionType } from './entities';
import type { AiReviewSectionType } from './entities';
import {
CreateConversationDto,
EditMessageDto,
@@ -248,7 +248,7 @@ export class AiChatController {
data: await this.service.confirmReviewStep(
req.user,
reviewId,
sectionKey as AiReviewSection['key'],
sectionKey,
),
};
}

View File

@@ -77,35 +77,6 @@ export async function executeGeneration(
);
}
tools.push(...A2UI_TOOL_SCHEMAS);
tools.push({
type: 'function' as const,
function: {
name: 'office_analyze',
description:
'分析上传的 Office 附件Excel/Word/PPTstats 统计、outline 结构、text 文本、get 读取指定区域、query 查询单元格/元素、issues 检查问题。文件较大或需要精确数据时使用。',
parameters: {
type: 'object',
properties: {
attachmentId: { type: 'integer', description: '要分析的附件 ID' },
action: {
type: 'string',
enum: ['stats', 'outline', 'text', 'get', 'query', 'issues'],
description: '分析动作',
},
path: {
type: 'string',
description: 'get 动作的路径,如 /Sheet1/A1:C20、/body/p[1]、/slide[1]',
},
selector: { type: 'string', description: 'query 动作的选择器,如 /Sheet1、row[姓名=张三]' },
maxLines: { type: 'integer', description: 'text 动作最多返回行数1-200' },
startRow: { type: 'integer', description: 'text 动作起始行(默认 1' },
},
required: ['attachmentId', 'action'],
additionalProperties: false,
},
},
});
tools = tools.filter((tool) => tool.function.name !== 'render_review');
const runtimeConfig = await context.configService.getRuntimeConfig();
const config = {
...runtimeConfig,

View File

@@ -0,0 +1,109 @@
import { BadRequestException } from '@nestjs/common';
import {
IMPORT_FIELD_ALIASES,
IMPORT_STEP_KEYS,
type ColumnMapping,
type ImportRunSettings,
type ImportStepKey,
} from '../imports/imports.types';
export interface ConfirmedMappingOptions {
/** 每个业务阶段允许的表头集合;为空时不校验表头是否存在。 */
allowedHeadersByStep?: Partial<Record<ImportStepKey, string[]>>;
}
/**
* 解析并校验用户确认的列映射。
* 与 start_import_wizard 工具、预检卡 resolve 端点共用,保证两条入口口径一致。
*/
export function parseConfirmedMapping(
raw: unknown,
options: ConfirmedMappingOptions = {},
): Partial<Record<ImportStepKey, ColumnMapping>> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new BadRequestException('mapping 参数格式错误');
}
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) {
throw new BadRequestException(`mapping 包含未知业务类型:${stepKey}`);
}
if (fields === undefined || fields === null) continue;
if (typeof fields !== 'object' || Array.isArray(fields)) {
throw new BadRequestException(`mapping 中「${stepKey}」的列映射格式错误`);
}
const typedStepKey = stepKey as ImportStepKey;
const allowedFields = new Set(Object.keys(IMPORT_FIELD_ALIASES[typedStepKey]));
const allowedHeaders = new Set(options.allowedHeadersByStep?.[typedStepKey] ?? []);
const columnMapping: ColumnMapping = {};
for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {
if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;
if (!allowedFields.has(field)) {
throw new BadRequestException(`mapping 中「${stepKey}」包含未知字段:${field}`);
}
if (typeof header !== 'string' || !header.trim()) continue;
const headerName = header.slice(0, 200);
if (allowedHeaders.size > 0 && !allowedHeaders.has(headerName)) {
throw new BadRequestException(
`${stepKey}」列映射「${headerName}」不在工作表表头中`,
);
}
columnMapping[field] = headerName;
}
mapping[typedStepKey] = columnMapping;
}
return mapping;
}
/** 从包含顶层 organization/updateExisting/duplicatePolicy/skipUnmatched 的对象解析策略设置。 */
export function parseConfirmedSettings(parsedRecord: Record<string, unknown>): ImportRunSettings {
const settings: ImportRunSettings = {};
if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) {
if (typeof parsedRecord.organization !== 'string') {
throw new BadRequestException('organization 必须是字符串');
}
const organization = parsedRecord.organization.trim().slice(0, 100);
if (organization) settings.organization = organization;
}
if (parsedRecord.updateExisting !== undefined) {
if (typeof parsedRecord.updateExisting !== 'boolean') {
throw new BadRequestException('updateExisting 必须是布尔值');
}
settings.updateExisting = parsedRecord.updateExisting;
}
if (parsedRecord.duplicatePolicy !== undefined) {
if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') {
throw new BadRequestException('duplicatePolicy 只能是 error 或 skip');
}
settings.duplicatePolicy = parsedRecord.duplicatePolicy;
}
if (parsedRecord.skipUnmatched !== undefined) {
if (typeof parsedRecord.skipUnmatched !== 'boolean') {
throw new BadRequestException('skipUnmatched 必须是布尔值');
}
settings.skipUnmatched = parsedRecord.skipUnmatched;
}
return settings;
}
/** 解析 resolve 端点嵌套的 settings 参数(缺省为空对象)。 */
export function parseNestedSettings(raw: unknown): ImportRunSettings {
if (raw === undefined || raw === null) return {};
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new BadRequestException('settings 参数格式错误');
}
return parseConfirmedSettings(raw as Record<string, unknown>);
}
export function isExcelAttachment(attachment: {
mimeType: string;
originalName: string;
}): boolean {
return (
attachment.mimeType.includes('spreadsheetml') ||
attachment.mimeType.includes('excel') ||
attachment.mimeType.includes('csv') ||
/\.(xlsx|csv)$/i.test(attachment.originalName)
);
}

View File

@@ -8,12 +8,13 @@ import { AiAttachmentService } from './ai-attachment.service';
import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiReviewService } from './ai-review.service';
import { AiChatService } from './ai-chat.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OfficeCliService } from './office-cli.service';
import {
AiAttachment,
AiA2uiSubmission,
AiConversation,
AiForm,
AiMessage,
@@ -25,6 +26,7 @@ import {
imports: [
TypeOrmModule.forFeature([
AiAttachment,
AiA2uiSubmission,
AiConversation,
AiForm,
AiMessage,
@@ -41,8 +43,8 @@ import {
AiChartService,
AiExcelReaderService,
AiFormService,
A2uiSubmissionsService,
AiReviewService,
OfficeCliService,
AiChatService,
AiModelStreamService,
],

View File

@@ -0,0 +1,128 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { AiReview } from './entities/ai-review.entity';
import type { AiReviewSectionType } from './entities/ai-review.entity';
import type { AiChatServiceContext } from './ai-chat.types';
import { reviewSectionType } from './ai-chat.types';
import type { AuthenticatedUser } from '../authorization';
async function loadReviewForConfirm(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
): Promise<AiReview> {
const review = await context.reviewService.findOwned(reviewId, user.id);
if (review.status === 'submitted') {
throw new ConflictException('导入已全部确认,无需重复确认');
}
if (review.status === 'expired') {
throw new ConflictException('导入预览已失效,请重新生成预览');
}
return review;
}
async function finalizeReview(
context: AiChatServiceContext,
updated: AiReview,
): Promise<Record<string, unknown>> {
await context.markReviewSubmittedOnMessage(
updated.assistantMessageId,
updated.conversationId,
updated,
);
return context.reviewService.serialize(updated);
}
async function logImportOp(
context: AiChatServiceContext,
user: AuthenticatedUser,
action: string,
detail: string,
): Promise<void> {
await context.opLog?.log({
userId: user.id,
username: user.username,
module: '批量导入',
action,
detail,
targetType: 'ai_review',
status: 'success',
});
}
export function assertReviewImportPermissions(
context: AiChatServiceContext,
user: AuthenticatedUser,
review: AiReview,
sectionKey?: string,
sectionType?: AiReviewSectionType,
): void {
const sectionPermission: Record<AiReviewSectionType, string> = {
students: 'student:create',
rooms: 'room:create',
transfers: 'occupancy:transfer',
checkins: 'occupancy:checkin',
};
const ability = context.abilityFactory.createForUser(user);
const sections = context.reviewService.parseSections(review.sectionsJson);
const types = new Set<AiReviewSectionType>();
if (sectionType) {
types.add(sectionType);
} else if (sectionKey) {
const section = sections.find((item) => item.key === sectionKey);
if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`);
types.add(reviewSectionType(section));
} else {
for (const section of sections) types.add(reviewSectionType(section));
}
for (const type of types) {
context.authorization.assertPermission(ability, sectionPermission[type]);
}
}
export async function confirmReviewStep(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
const review = await loadReviewForConfirm(context, user, reviewId);
assertReviewImportPermissions(context, user, review, sectionKey);
const { review: updated, message } = await context.reviewService.submitSection(
review.id,
user.id,
sectionKey,
);
await logImportOp(
context,
user,
'确认导入分表',
`${review.title}」分表「${sectionKey}」:${message}`,
);
return finalizeReview(context, updated);
}
export async function confirmReviewGroup(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') {
throw new BadRequestException(`业务类型不支持: ${String(type)}`);
}
const review = await loadReviewForConfirm(context, user, reviewId);
assertReviewImportPermissions(context, user, review, undefined, type);
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
const sectionTitles = context.reviewService
.parseSections(updated.sectionsJson)
.filter((section) => section.type === type)
.map((section) => section.title)
.join('、');
await logImportOp(
context,
user,
'确认导入分组',
`${review.title}」分组「${type}」:${sectionTitles}`,
);
return finalizeReview(context, updated);
}

View File

@@ -0,0 +1,348 @@
import { DataSource, Repository } from 'typeorm';
import { AiConfigService } from '../ai-config/ai-config.service';
import { AgentToolExecutor } from '../agent-tools/agent-tool.executor';
import {
AgentToolContextFactory,
type AgentSkillDescriptor,
} from '../agent-tools/agent-tool.types';
import { AuthorizationService, CaslAbilityFactory, type AuthenticatedUser } from '../authorization';
import { AiAttachmentService } from './ai-attachment.service';
import { ImportsService } from '../imports/imports.service';
import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { AiReviewService } from './ai-review.service';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import {
AiConversation,
AiMessage,
AiReview,
AiToolRun,
type AiReviewSectionType,
} from './entities';
import type {
AiChatServiceContext,
AiSseEmitter,
GenerationInput,
ModelContentPart,
ModelMessage,
ModelToolCall,
PublicConversation,
} from './ai-chat.types';
import {
listConversations,
createConversation,
updateConversation,
deleteConversation,
deleteAllConversations,
getMessages,
deleteMessage,
} from './ai-chat.conversations';
import {
streamMessage,
regenerateMessage,
editMessage,
} from './ai-chat.streaming';
import {
resolveFormConversationId,
resolveReviewConversationId,
submitForm,
submitReview,
confirmReviewStep,
confirmReviewGroup,
assertReviewImportPermissions,
a2uiSubmitInfo,
buildFormSubmitModelContent,
markFormSubmittedOnMessage,
a2uiReviewSubmitInfo,
buildReviewSubmitModelContent,
markReviewSubmittedOnMessage,
} from './ai-chat.submissions';
import { denyWriteTool, executeTool } from './ai-chat.tools';
import { executeStartImportWizard } from './ai-chat.tool-actions';
export abstract class AiChatServiceBase implements AiChatServiceContext {
readonly activeConversations = new Set<number>();
abstract listSkills(user: AuthenticatedUser): AgentSkillDescriptor[];
abstract serializeMessage(message: AiMessage): Record<string, unknown>;
abstract redactText(value: string): string;
abstract summarize(value: unknown): string | null;
abstract safeStructured(value: unknown): unknown;
abstract parseToolArguments(value: string): unknown;
abstract safeToolName(name: string): string;
abstract throwIfAborted(signal: AbortSignal): void;
abstract errorCode(error: unknown): string;
abstract assertGeneratedLength(reasoning: string, content: string): void;
abstract buildContext(
conversationId: number,
focusUserMessageId: number,
focusContent: string | ModelContentPart[],
skillKey: string | null,
supportsVision: boolean,
): Promise<ModelMessage[]>;
abstract buildUserContent(
text: string,
attachments: any[],
supportsVision: boolean,
): Promise<string | ModelContentPart[]>;
abstract truncateText(value: string, max: number): string;
abstract metadataSkillKey(metadata: Record<string, unknown> | null): string | null;
abstract normalizeTitle(title?: string): string;
abstract titleFromMessage(message: string): string;
abstract assertSkillAvailable(user: AuthenticatedUser, skillKey?: string | null): void;
abstract requireOwnedConversation(userId: number, id: number): Promise<AiConversation>;
abstract acquireConversation(conversationId: number): Promise<void>;
abstract executeGeneration(input: GenerationInput): Promise<void>;
constructor(
readonly conversations: Repository<AiConversation>,
readonly messages: Repository<AiMessage>,
readonly toolRuns: Repository<AiToolRun>,
readonly dataSource: DataSource,
readonly configService: AiConfigService,
readonly toolExecutor: AgentToolExecutor,
readonly modelStream: AiModelStreamService,
readonly attachmentService: AiAttachmentService,
readonly formService: AiFormService,
readonly reviewService: AiReviewService,
readonly chartService: AiChartService,
readonly abilityFactory: CaslAbilityFactory,
readonly authorization: AuthorizationService,
readonly a2uiSubmissions?: A2uiSubmissionsService,
readonly excelReader?: AiExcelReaderService,
readonly importsService?: ImportsService,
readonly opLog?: OperationLogsService,
) {}
a2uiSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiSubmitInfo(metadata);
}
a2uiReviewSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiReviewSubmitInfo(metadata);
}
buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
submissionId?: string;
fieldErrors?: Array<{ field: string; message: string }>;
}): string {
return buildFormSubmitModelContent(submit);
}
buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
submissionId?: string;
nextSteps?: Array<{ key: string; label: string }>;
}): string {
return buildReviewSubmitModelContent(submit);
}
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void> {
return markFormSubmittedOnMessage(this, assistantMessageId, conversationId);
}
markReviewSubmittedOnMessage(
assistantMessageId: number,
conversationId: number,
review?: AiReview,
): Promise<void> {
return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review);
}
assertReviewImportPermissions(
user: AuthenticatedUser,
review: AiReview,
sectionKey?: string,
sectionType?: AiReviewSectionType,
): void {
return assertReviewImportPermissions(this, user, review, sectionKey, sectionType);
}
executeTool(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
allowedSkillKey: string | null,
allowWriteTools: boolean,
reviewSubmitted: boolean,
userId: number,
emit: AiSseEmitter,
): Promise<string> {
return executeTool(
this,
messageId,
call,
context,
allowedSkillKey,
allowWriteTools,
reviewSubmitted,
userId,
emit,
);
}
denyWriteTool(
messageId: number,
call: ModelToolCall,
emit: AiSseEmitter,
): Promise<string> {
return denyWriteTool(this, messageId, call, emit);
}
executeStartImportWizard(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executeStartImportWizard(this, messageId, call, context, emit);
}
listConversations(userId: number): Promise<PublicConversation[]> {
return listConversations(this, userId);
}
createConversation(
user: AuthenticatedUser,
title?: string,
lockedSkillKey?: string | null,
): Promise<PublicConversation> {
return createConversation(this, user, title, lockedSkillKey);
}
updateConversation(
user: AuthenticatedUser,
id: number,
dto: { title?: string; lockedSkillKey?: string | null },
): Promise<PublicConversation> {
return updateConversation(this, user, id, dto);
}
deleteConversation(userId: number, id: number): Promise<void> {
return deleteConversation(this, userId, id);
}
deleteAllConversations(userId: number): Promise<number> {
return deleteAllConversations(this, userId);
}
getMessages(userId: number, conversationId: number, page = 1, limit = 50) {
return getMessages(this, userId, conversationId, page, limit);
}
deleteMessage(
userId: number,
conversationId: number,
messageId: number,
): Promise<{ deletedIds: number[] }> {
return deleteMessage(this, userId, conversationId, messageId);
}
streamMessage(
user: AuthenticatedUser,
conversationId: number,
dto: {
message: string;
attachmentIds?: number[];
clientRequestId: string;
skillKey?: string | null;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return streamMessage(this, user, conversationId, dto, signal, emit, onReady);
}
regenerateMessage(
user: AuthenticatedUser,
conversationId: number,
assistantMessageId: number,
clientRequestId: string,
reasoningEffort: string | null | undefined,
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return regenerateMessage(
this,
user,
conversationId,
assistantMessageId,
clientRequestId,
reasoningEffort,
signal,
emit,
onReady,
);
}
editMessage(
user: AuthenticatedUser,
conversationId: number,
messageId: number,
dto: { content: string; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady);
}
resolveFormConversationId(userId: number, formId: string): Promise<number> {
return resolveFormConversationId(this, userId, formId);
}
resolveReviewConversationId(userId: number, reviewId: string): Promise<number> {
return resolveReviewConversationId(this, userId, reviewId);
}
submitForm(
user: AuthenticatedUser,
formId: string,
dto: {
values: Record<string, unknown>;
clientRequestId: string;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return submitForm(this, user, formId, dto, signal, emit, onReady);
}
submitReview(
user: AuthenticatedUser,
reviewId: string,
dto: { clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
}
confirmReviewStep(
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
return confirmReviewStep(this, user, reviewId, sectionKey);
}
confirmReviewGroup(
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
return confirmReviewGroup(this, user, reviewId, type);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,32 +14,16 @@ import { AiChartService } from './ai-chart.service';
import { AiExcelReaderService } from './ai-excel-reader.service';
import { AiFormService } from './ai-form.service';
import { AiReviewService } from './ai-review.service';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiModelStreamService } from './ai-model-stream.service';
import { OfficeCliService } from './office-cli.service';
import {
AiConversation,
AiMessage,
AiReview,
AiToolRun,
type AiReviewSectionType,
} from './entities';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AiConversation, AiMessage, AiToolRun } from './entities';
import type {
AiChatServiceContext,
AiSseEmitter,
GenerationInput,
ModelContentPart,
ModelMessage,
ModelToolCall,
PublicConversation,
} from './ai-chat.types';
import {
listConversations,
createConversation,
updateConversation,
deleteConversation,
deleteAllConversations,
getMessages,
deleteMessage,
requireOwnedConversation,
acquireConversation,
normalizeTitle,
@@ -49,30 +33,7 @@ import {
truncateText,
serializeMessage,
} from './ai-chat.conversations';
import {
streamMessage,
regenerateMessage,
editMessage,
buildContext,
buildUserContent,
} from './ai-chat.streaming';
import {
resolveFormConversationId,
resolveReviewConversationId,
submitForm,
submitReview,
confirmReviewStep,
confirmReviewGroup,
assertReviewImportPermissions,
a2uiSubmitInfo,
buildFormSubmitModelContent,
markFormSubmittedOnMessage,
a2uiReviewSubmitInfo,
buildReviewSubmitModelContent,
markReviewSubmittedOnMessage,
} from './ai-chat.submissions';
import { denyWriteTool, executeTool } from './ai-chat.tools';
import { executeStartImportWizard } from './ai-chat.tool-actions';
import { buildContext, buildUserContent } from './ai-chat.streaming';
import { executeGeneration } from './ai-chat.generation';
import {
assertGeneratedLength,
@@ -83,32 +44,54 @@ import {
safeToolName,
throwIfAborted,
} from './ai-chat.helpers';
import { AiChatServiceBase } from './ai-chat.service-base';
@Injectable()
export class AiChatService implements AiChatServiceContext {
readonly activeConversations = new Set<number>();
export class AiChatService extends AiChatServiceBase {
private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value));
constructor(
@InjectRepository(AiConversation)
readonly conversations: Repository<AiConversation>,
conversations: Repository<AiConversation>,
@InjectRepository(AiMessage)
readonly messages: Repository<AiMessage>,
messages: Repository<AiMessage>,
@InjectRepository(AiToolRun)
readonly toolRuns: Repository<AiToolRun>,
readonly dataSource: DataSource,
readonly configService: AiConfigService,
readonly toolExecutor: AgentToolExecutor,
readonly modelStream: AiModelStreamService,
readonly attachmentService: AiAttachmentService,
readonly formService: AiFormService,
readonly reviewService: AiReviewService,
readonly chartService: AiChartService,
readonly abilityFactory: CaslAbilityFactory,
readonly authorization: AuthorizationService,
readonly excelReader?: AiExcelReaderService,
readonly officeCli?: OfficeCliService,
readonly importsService?: ImportsService,
) {}
toolRuns: Repository<AiToolRun>,
dataSource: DataSource,
configService: AiConfigService,
toolExecutor: AgentToolExecutor,
modelStream: AiModelStreamService,
attachmentService: AiAttachmentService,
formService: AiFormService,
reviewService: AiReviewService,
chartService: AiChartService,
abilityFactory: CaslAbilityFactory,
authorization: AuthorizationService,
a2uiSubmissions?: A2uiSubmissionsService,
excelReader?: AiExcelReaderService,
importsService?: ImportsService,
opLog?: OperationLogsService,
) {
super(
conversations,
messages,
toolRuns,
dataSource,
configService,
toolExecutor,
modelStream,
attachmentService,
formService,
reviewService,
chartService,
abilityFactory,
authorization,
a2uiSubmissions,
excelReader,
importsService,
opLog,
);
}
listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] {
return this.toolExecutor.listSkills(AgentToolContextFactory.fromAuthenticatedUser(user));
@@ -162,49 +145,6 @@ export class AiChatService implements AiChatServiceContext {
return assertGeneratedLength(reasoning, content);
}
private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value));
a2uiSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiSubmitInfo(metadata);
}
a2uiReviewSubmitInfo(metadata: Record<string, unknown> | null) {
return a2uiReviewSubmitInfo(metadata);
}
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string {
return buildFormSubmitModelContent(submit);
}
buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
}): string {
return buildReviewSubmitModelContent(submit);
}
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void> {
return markFormSubmittedOnMessage(this, assistantMessageId, conversationId);
}
markReviewSubmittedOnMessage(
assistantMessageId: number,
conversationId: number,
review?: AiReview,
): Promise<void> {
return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review);
}
assertReviewImportPermissions(
user: AuthenticatedUser,
review: AiReview,
sectionKey?: string,
sectionType?: AiReviewSectionType,
): void {
return assertReviewImportPermissions(this, user, review, sectionKey, sectionType);
}
buildContext(
conversationId: number,
focusUserMessageId: number,
@@ -251,189 +191,7 @@ export class AiChatService implements AiChatServiceContext {
return acquireConversation(this, conversationId);
}
executeTool(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
allowedSkillKey: string | null,
allowWriteTools: boolean,
reviewSubmitted: boolean,
userId: number,
emit: AiSseEmitter,
): Promise<string> {
return executeTool(
this,
messageId,
call,
context,
allowedSkillKey,
allowWriteTools,
reviewSubmitted,
userId,
emit,
);
}
denyWriteTool(
messageId: number,
call: ModelToolCall,
emit: AiSseEmitter,
): Promise<string> {
return denyWriteTool(this, messageId, call, emit);
}
executeStartImportWizard(
messageId: number,
call: ModelToolCall,
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
emit: AiSseEmitter,
): Promise<string> {
return executeStartImportWizard(this, messageId, call, context, emit);
}
executeGeneration(input: GenerationInput): Promise<void> {
return executeGeneration(this, input);
}
listConversations(userId: number): Promise<PublicConversation[]> {
return listConversations(this, userId);
}
createConversation(
user: AuthenticatedUser,
title?: string,
lockedSkillKey?: string | null,
): Promise<PublicConversation> {
return createConversation(this, user, title, lockedSkillKey);
}
updateConversation(
user: AuthenticatedUser,
id: number,
dto: { title?: string; lockedSkillKey?: string | null },
): Promise<PublicConversation> {
return updateConversation(this, user, id, dto);
}
deleteConversation(userId: number, id: number): Promise<void> {
return deleteConversation(this, userId, id);
}
deleteAllConversations(userId: number): Promise<number> {
return deleteAllConversations(this, userId);
}
getMessages(userId: number, conversationId: number, page = 1, limit = 50) {
return getMessages(this, userId, conversationId, page, limit);
}
deleteMessage(
userId: number,
conversationId: number,
messageId: number,
): Promise<{ deletedIds: number[] }> {
return deleteMessage(this, userId, conversationId, messageId);
}
streamMessage(
user: AuthenticatedUser,
conversationId: number,
dto: {
message: string;
attachmentIds?: number[];
clientRequestId: string;
skillKey?: string | null;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return streamMessage(this, user, conversationId, dto, signal, emit, onReady);
}
regenerateMessage(
user: AuthenticatedUser,
conversationId: number,
assistantMessageId: number,
clientRequestId: string,
reasoningEffort: string | null | undefined,
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return regenerateMessage(
this,
user,
conversationId,
assistantMessageId,
clientRequestId,
reasoningEffort,
signal,
emit,
onReady,
);
}
editMessage(
user: AuthenticatedUser,
conversationId: number,
messageId: number,
dto: { content: string; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady);
}
resolveFormConversationId(userId: number, formId: string): Promise<number> {
return resolveFormConversationId(this, userId, formId);
}
resolveReviewConversationId(userId: number, reviewId: string): Promise<number> {
return resolveReviewConversationId(this, userId, reviewId);
}
submitForm(
user: AuthenticatedUser,
formId: string,
dto: {
values: Record<string, unknown>;
clientRequestId: string;
reasoningEffort?: string | null;
},
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return submitForm(this, user, formId, dto, signal, emit, onReady);
}
submitReview(
user: AuthenticatedUser,
reviewId: string,
dto: { clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
}
confirmReviewStep(
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
return confirmReviewStep(this, user, reviewId, sectionKey);
}
confirmReviewGroup(
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
return confirmReviewGroup(this, user, reviewId, type);
}
}

View File

@@ -12,9 +12,6 @@ import type {
} from './ai-chat.types';
import {
DEFAULT_TITLE,
MAX_ATTACHMENT_TEXT_CHARS,
MAX_CONTEXT_CHARS,
MAX_FOCUS_CONTENT_CHARS,
MAX_HISTORY_MESSAGES,
SYSTEM_PROMPT,
} from './ai-chat.types';
@@ -301,7 +298,6 @@ export async function buildContext(
? `${SYSTEM_PROMPT}\n当前会话已锁定技能${skillKey}。只能调用该技能内的工具。`
: SYSTEM_PROMPT;
const selected: ModelMessage[] = [];
let chars = systemPrompt.length;
for (const message of history) {
if (message.status !== 'completed') continue;
const content =
@@ -310,15 +306,6 @@ export async function buildContext(
: message.role === 'user' && message.attachments?.length
? await context.buildUserContent(message.content, message.attachments, supportsVision)
: message.content;
const contentChars =
typeof content === 'string'
? content.length
: content.reduce(
(total, part) => total + (part.type === 'text' ? part.text.length : 1024),
0,
);
if (chars + contentChars > MAX_CONTEXT_CHARS) break;
chars += contentChars;
selected.push({ role: message.role, content } as ModelMessage);
if (selected.length >= MAX_HISTORY_MESSAGES) break;
}
@@ -337,38 +324,15 @@ export async function buildUserContent(
const contentParts: ModelContentPart[] = [];
for (const part of parts) {
if (part.text !== undefined) {
const isSpreadsheet = (part.attachment.mimeType ?? '').includes('spreadsheetml');
const isLarge = part.text.length > MAX_ATTACHMENT_TEXT_CHARS;
if (isSpreadsheet && isLarge && context.excelReader) {
let overview: string | null = null;
try {
const buffer = await context.attachmentService.readStoredBuffer(part.attachment);
overview = (await context.excelReader.overview(buffer, 12)).text;
} catch {
overview = null;
}
const content = overview ?? context.truncateText(part.text, MAX_ATTACHMENT_TEXT_CHARS);
textSections.push(
`\n\n[附件:${part.attachment.originalName}附件ID=${part.attachment.id}]\n${content}\n\n[提示:以上仅为文件概览(工作表、行数与前几行样本)。文件较大,需要具体数据时请调用 office_analyze 工具outline/get/query/text按需读取attachmentId 使用上面的附件ID。]`,
);
} else {
textSections.push(
`\n\n[附件:${part.attachment.originalName}附件ID=${part.attachment.id}]\n${context.truncateText(
part.text,
MAX_ATTACHMENT_TEXT_CHARS,
)}`,
);
}
textSections.push(
`\n\n[附件:${part.attachment.originalName}附件ID=${part.attachment.id}]\n${part.text}`,
);
} else if (part.imageDataUrl) {
textSections.push(`\n\n[图片附件:${part.attachment.originalName}]`);
contentParts.push({ type: 'image_url', image_url: { url: part.imageDataUrl } });
}
}
const combinedText = textSections.join('');
const boundedText =
combinedText.length > MAX_FOCUS_CONTENT_CHARS
? context.truncateText(combinedText, MAX_FOCUS_CONTENT_CHARS)
: combinedText;
if (!contentParts.length) return boundedText;
return [{ type: 'text', text: boundedText }, ...contentParts];
if (!contentParts.length) return combinedText;
return [{ type: 'text', text: combinedText }, ...contentParts];
}

View File

@@ -0,0 +1,230 @@
import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types';
import { DEFAULT_TITLE } from './ai-chat.types';
import type { AuthenticatedUser } from '../authorization';
import { suggestNextStepsForPrincipal } from '../agent-context/business-context.service';
import { buildA2uiArtifact } from './ai-a2ui.artifact';
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
import { AiA2uiSubmission } from './entities';
import { assertReviewImportPermissions } from './ai-chat.review-confirm';
import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime';
function submissionsService(
context: AiChatServiceContext,
): A2uiSubmissionsService {
return (
context.a2uiSubmissions ??
new A2uiSubmissionsService(context.dataSource.getRepository(AiA2uiSubmission))
);
}
export async function submitForm(
context: AiChatServiceContext,
user: AuthenticatedUser,
formId: string,
dto: { values: Record<string, unknown>; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
const form = await context.formService.findOwnedPending(formId, user.id);
const conversation = await context.requireOwnedConversation(user.id, form.conversationId);
const values = context.formService.validateValues(form, dto.values);
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
context.assertSkillAvailable(user, effectiveSkillKey);
const submissions = submissionsService(context);
const recorded = await submissions.recordSubmission({
artifactId: formId,
clientRequestId: dto.clientRequestId,
status: 'created',
});
if (!recorded.created) {
emit('error', { message: '该表单已提交过,请勿重复提交' });
emit('done', {});
return;
}
await context.acquireConversation(conversation.id);
try {
const summary = `已提交表单「${form.title}`;
const saved = await context.dataSource.transaction(async (manager) =>
persistExchange(
context,
manager,
conversation,
user.id,
summary,
dto.clientRequestId,
effectiveSkillKey,
{
a2uiSubmit: {
formId: form.id,
formTitle: form.title,
values,
submissionId: String(recorded.submission.id),
fieldErrors: [],
},
},
undefined,
conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined,
),
);
await context.formService.markSubmitted(form, values);
await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id);
emit('ui.artifact', {
messageId: form.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'form',
id: form.id,
status: 'submitted',
messageId: form.assistantMessageId,
conversationId: conversation.id,
payload: { ...context.formService.serialize(form), status: 'submitted' },
}),
});
await runGenerationAndRelease(context, {
user,
conversation,
userMessage: saved.userMessage,
assistant: saved.assistantMessage,
clientRequestId: dto.clientRequestId,
effectiveSkillKey,
focusContent: summary,
reasoningEffort: dto.reasoningEffort ?? null,
signal,
emit,
onReady,
}, conversation.id);
} finally {
context.activeConversations.delete(conversation.id);
}
}
export async function submitReview(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
dto: { clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
const review = await context.reviewService.findOwnedPending(reviewId, user.id);
const conversation = await context.requireOwnedConversation(user.id, review.conversationId);
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
context.assertSkillAvailable(user, effectiveSkillKey);
assertReviewImportPermissions(context, user, review);
const submissions = submissionsService(context);
const recorded = await submissions.recordSubmission({
artifactId: reviewId,
clientRequestId: dto.clientRequestId,
status: 'created',
});
if (!recorded.created) {
emit('error', { message: '该导入预览已确认过,请勿重复提交' });
emit('done', {});
return;
}
await context.acquireConversation(conversation.id);
try {
const { review: updatedReview, result } = await context.reviewService.submitAll(
review.id,
user.id,
);
const sectionTypes = context.reviewService
.parseSections(review.sectionsJson)
.map((section) => section.type)
.filter(Boolean);
const completedStageKeys = [
...new Set(
sectionTypes.map((type) =>
type === 'students'
? 'profile'
: type === 'rooms'
? 'room'
: 'checkin',
),
),
];
const nextSteps = suggestNextStepsForPrincipal(
{ permissions: user.permissions, isSuperAdmin: user.isSuperAdmin },
completedStageKeys,
).map((step) => ({ key: step.key, label: step.label }));
const summary = `已确认导入「${review.title}」:${result.message}`;
await context.opLog?.log({
userId: user.id,
username: user.username,
module: '批量导入',
action: '确认导入全部',
detail: `${review.title}${result.message}`,
targetType: 'ai_review',
status: 'success',
});
const saved = await context.dataSource.transaction(async (manager) => {
const exchange = await persistExchange(
context,
manager,
conversation,
user.id,
summary,
dto.clientRequestId,
effectiveSkillKey,
{
a2uiReviewSubmit: {
reviewId: review.id,
reviewTitle: review.title,
resultMessage: result.message,
submissionId: String(recorded.submission.id),
nextSteps,
},
},
undefined,
conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined,
);
return { ...exchange, result };
});
const serialized = context.reviewService.serialize(updatedReview);
onReady();
emit('ui.review', {
messageId: updatedReview.assistantMessageId,
review: serialized,
});
emit('ui.artifact', {
messageId: updatedReview.assistantMessageId,
artifact: buildA2uiArtifact({
type: 'review',
id: review.id,
status: 'submitted',
messageId: updatedReview.assistantMessageId,
conversationId: conversation.id,
payload: serialized,
}),
});
await context.markReviewSubmittedOnMessage(
updatedReview.assistantMessageId,
conversation.id,
updatedReview,
);
await runGenerationAndRelease(context, {
user,
conversation,
userMessage: saved.userMessage,
assistant: saved.assistantMessage,
clientRequestId: dto.clientRequestId,
effectiveSkillKey,
focusContent: saved.result.message,
reasoningEffort: dto.reasoningEffort ?? null,
signal,
emit,
onReady,
}, conversation.id);
} finally {
context.activeConversations.delete(conversation.id);
}
}

View File

@@ -0,0 +1,82 @@
import { EntityManager } from 'typeorm';
import { AiConversation, AiMessage } from './entities';
import type {
AiChatServiceContext,
AiSseEmitter,
ModelContentPart,
} from './ai-chat.types';
import type { AuthenticatedUser } from '../authorization';
export async function persistExchange(
context: AiChatServiceContext,
manager: EntityManager,
conversation: AiConversation,
userId: number,
userContent: string,
clientRequestId: string | undefined,
skillKey: string | null,
metadata?: Record<string, unknown>,
attachments?: any[],
titleUpdate?: string,
): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> {
const userMessage = await manager.save(
AiMessage,
manager.create(AiMessage, {
conversationId: conversation.id,
role: 'user',
content: userContent,
reasoningContent: null,
status: 'completed',
errorCode: null,
replyToMessageId: null,
metadata: { clientRequestId, skillKey, ...metadata },
attachments,
}),
);
const assistantMessage = await manager.save(
AiMessage,
manager.create(AiMessage, {
conversationId: conversation.id,
role: 'assistant',
content: '',
reasoningContent: null,
status: 'pending',
errorCode: null,
replyToMessageId: userMessage.id,
metadata: { clientRequestId, skillKey },
}),
);
await manager.update(
AiConversation,
{ id: conversation.id, userId },
{
lastMessageAt: new Date(),
...(titleUpdate ? { title: titleUpdate } : {}),
},
);
return { userMessage, assistantMessage };
}
export async function runGenerationAndRelease(
context: AiChatServiceContext,
input: {
user: AuthenticatedUser;
conversation: AiConversation;
userMessage: AiMessage;
assistant: AiMessage;
clientRequestId: string;
effectiveSkillKey: string | null;
focusContent: string | ModelContentPart[];
reasoningEffort?: string | null;
signal: AbortSignal;
emit: AiSseEmitter;
onReady: () => void;
},
conversationId: number,
): Promise<void> {
try {
await context.executeGeneration(input);
} finally {
context.activeConversations.delete(conversationId);
}
}

View File

@@ -1,17 +1,7 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { AiReview } from './entities/ai-review.entity';
import { AiConversation, AiMessage } from './entities';
import type { AiReviewSectionType } from './entities/ai-review.entity';
import type {
AiChatServiceContext,
AiSseEmitter,
} from './ai-chat.types';
import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types';
import type { AuthenticatedUser } from '../authorization';
export async function resolveFormConversationId(
@@ -32,375 +22,18 @@ export async function resolveReviewConversationId(
return review.conversationId;
}
export function assertReviewImportPermissions(
context: AiChatServiceContext,
user: AuthenticatedUser,
review: AiReview,
sectionKey?: string,
sectionType?: AiReviewSectionType,
): void {
const sectionPermission: Record<AiReviewSectionType, string> = {
students: 'student:create',
rooms: 'room:create',
transfers: 'occupancy:transfer',
checkins: 'occupancy:checkin',
};
const ability = context.abilityFactory.createForUser(user);
const sections = context.reviewService.parseSections(review.sectionsJson);
const types = new Set<AiReviewSectionType>();
if (sectionType) {
types.add(sectionType);
} else if (sectionKey) {
const section = sections.find((item) => item.key === sectionKey);
if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`);
types.add(reviewSectionType(section));
} else {
for (const section of sections) types.add(reviewSectionType(section));
}
for (const type of types) {
context.authorization.assertPermission(ability, sectionPermission[type]);
}
}
export async function submitForm(
context: AiChatServiceContext,
user: AuthenticatedUser,
formId: string,
dto: { values: Record<string, unknown>; clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
const form = await context.formService.findOwnedPending(formId, user.id);
const conversation = await context.requireOwnedConversation(user.id, form.conversationId);
const values = context.formService.validateValues(form, dto.values);
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
context.assertSkillAvailable(user, effectiveSkillKey);
await context.acquireConversation(conversation.id);
try {
const summary = `已提交表单「${form.title}`;
const saved = await context.dataSource.transaction(async (manager) =>
persistExchange(
context,
manager,
conversation,
user.id,
summary,
dto.clientRequestId,
effectiveSkillKey,
{ a2uiSubmit: { formId: form.id, formTitle: form.title, values } },
undefined,
conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined,
),
);
await context.formService.markSubmitted(form, values);
await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id);
await runGenerationAndRelease(context, {
user,
conversation,
userMessage: saved.userMessage,
assistant: saved.assistantMessage,
clientRequestId: dto.clientRequestId,
effectiveSkillKey,
focusContent: summary,
reasoningEffort: dto.reasoningEffort ?? null,
signal,
emit,
onReady,
}, conversation.id);
} finally {
context.activeConversations.delete(conversation.id);
}
}
export async function submitReview(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
dto: { clientRequestId: string; reasoningEffort?: string | null },
signal: AbortSignal,
emit: AiSseEmitter,
onReady: () => void,
): Promise<void> {
const review = await context.reviewService.findOwnedPending(reviewId, user.id);
const conversation = await context.requireOwnedConversation(user.id, review.conversationId);
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
context.assertSkillAvailable(user, effectiveSkillKey);
assertReviewImportPermissions(context, user, review);
await context.acquireConversation(conversation.id);
try {
const { review: updatedReview, result } = await context.reviewService.submitAll(
review.id,
user.id,
);
const summary = `已确认导入「${review.title}」:${result.message}`;
const saved = await context.dataSource.transaction(async (manager) => {
const exchange = await persistExchange(
context,
manager,
conversation,
user.id,
summary,
dto.clientRequestId,
effectiveSkillKey,
{
a2uiReviewSubmit: {
reviewId: review.id,
reviewTitle: review.title,
resultMessage: result.message,
},
},
undefined,
conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined,
);
return { ...exchange, result };
});
const serialized = context.reviewService.serialize(updatedReview);
onReady();
emit('ui.review', {
messageId: updatedReview.assistantMessageId,
review: serialized,
});
await context.markReviewSubmittedOnMessage(
updatedReview.assistantMessageId,
conversation.id,
updatedReview,
);
await runGenerationAndRelease(context, {
user,
conversation,
userMessage: saved.userMessage,
assistant: saved.assistantMessage,
clientRequestId: dto.clientRequestId,
effectiveSkillKey,
focusContent: saved.result.message,
reasoningEffort: dto.reasoningEffort ?? null,
signal,
emit,
onReady,
}, conversation.id);
} finally {
context.activeConversations.delete(conversation.id);
}
}
export async function confirmReviewStep(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
sectionKey: string,
): Promise<Record<string, unknown>> {
const review = await context.reviewService.findOwned(reviewId, user.id);
if (review.status === 'submitted') {
throw new ConflictException('导入已全部确认,无需重复确认');
}
if (review.status === 'expired') {
throw new ConflictException('导入预览已失效,请重新生成预览');
}
assertReviewImportPermissions(context, user, review, sectionKey);
const { review: updated } = await context.reviewService.submitSection(
review.id,
user.id,
sectionKey,
);
await context.markReviewSubmittedOnMessage(
updated.assistantMessageId,
updated.conversationId,
updated,
);
return context.reviewService.serialize(updated);
}
export async function confirmReviewGroup(
context: AiChatServiceContext,
user: AuthenticatedUser,
reviewId: string,
type: AiReviewSectionType,
): Promise<Record<string, unknown>> {
if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') {
throw new BadRequestException(`业务类型不支持: ${String(type)}`);
}
const review = await context.reviewService.findOwned(reviewId, user.id);
if (review.status === 'submitted') {
throw new ConflictException('导入已全部确认,无需重复确认');
}
if (review.status === 'expired') {
throw new ConflictException('导入预览已失效,请重新生成预览');
}
assertReviewImportPermissions(context, user, review, undefined, type);
const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);
await context.markReviewSubmittedOnMessage(
updated.assistantMessageId,
updated.conversationId,
updated,
);
return context.reviewService.serialize(updated);
}
export function a2uiSubmitInfo(
metadata: Record<string, unknown> | null,
): { title: string; values: Record<string, unknown> } | null {
const submit = metadata?.a2uiSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
const values =
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
? (record.values as Record<string, unknown>)
: {};
return { title, values };
}
export function buildFormSubmitModelContent(submit: {
title: string;
values: Record<string, unknown>;
}): string {
let json: string;
try {
json = JSON.stringify(submit.values);
} catch {
json = '[无法序列化]';
}
return `【表单提交:${submit.title}\n提交值JSON${json.slice(0, 32 * 1024)}\n用户已在表单中确认你可以执行允许的写操作工具。`;
}
export async function markFormSubmittedOnMessage(
context: AiChatServiceContext,
assistantMessageId: number,
conversationId: number,
): Promise<void> {
const assistant = await context.messages.findOne({
where: { id: assistantMessageId, conversationId },
});
const a2ui = assistant?.metadata?.a2uiForm;
if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) {
assistant.metadata = {
...assistant.metadata,
a2uiForm: { ...(a2ui as Record<string, unknown>), status: 'submitted' },
};
await context.messages.save(assistant);
}
}
export function a2uiReviewSubmitInfo(
metadata: Record<string, unknown> | null,
): { reviewId: string; reviewTitle: string; resultMessage: string } | null {
const submit = metadata?.a2uiReviewSubmit;
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
const record = submit as Record<string, unknown>;
if (typeof record.reviewId !== 'string') return null;
return {
reviewId: record.reviewId,
reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入',
resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成',
};
}
export function buildReviewSubmitModelContent(submit: {
reviewId: string;
reviewTitle: string;
resultMessage: string;
}): string {
return `【批量导入已确认:${submit.reviewTitle}\n${submit.resultMessage}\n数据已由系统入库不要再次调用写入工具直接向用户汇报导入结果即可。`;
}
export async function markReviewSubmittedOnMessage(
context: AiChatServiceContext,
assistantMessageId: number,
conversationId: number,
review?: AiReview,
): Promise<void> {
const assistant = await context.messages.findOne({
where: { id: assistantMessageId, conversationId },
});
const a2ui = assistant?.metadata?.a2uiReview;
if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) {
assistant.metadata = {
...assistant.metadata,
a2uiReview: review
? context.reviewService.serialize(review)
: { ...(a2ui as Record<string, unknown>), status: 'submitted' },
};
await context.messages.save(assistant);
}
}
export async function persistExchange(
context: AiChatServiceContext,
manager: EntityManager,
conversation: AiConversation,
userId: number,
userContent: string,
clientRequestId: string | undefined,
skillKey: string | null,
metadata?: Record<string, unknown>,
attachments?: any[],
titleUpdate?: string,
): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> {
const userMessage = await manager.save(
AiMessage,
manager.create(AiMessage, {
conversationId: conversation.id,
role: 'user',
content: userContent,
reasoningContent: null,
status: 'completed',
errorCode: null,
replyToMessageId: null,
metadata: { clientRequestId, skillKey, ...metadata },
attachments,
}),
);
const assistantMessage = await manager.save(
AiMessage,
manager.create(AiMessage, {
conversationId: conversation.id,
role: 'assistant',
content: '',
reasoningContent: null,
status: 'pending',
errorCode: null,
replyToMessageId: userMessage.id,
metadata: { clientRequestId, skillKey },
}),
);
await manager.update(
AiConversation,
{ id: conversation.id, userId },
{
lastMessageAt: new Date(),
...(titleUpdate ? { title: titleUpdate } : {}),
},
);
return { userMessage, assistantMessage };
}
export async function runGenerationAndRelease(
context: AiChatServiceContext,
input: {
user: AuthenticatedUser;
conversation: AiConversation;
userMessage: AiMessage;
assistant: AiMessage;
clientRequestId: string;
effectiveSkillKey: string | null;
focusContent: string | import('./ai-chat.types').ModelContentPart[];
reasoningEffort?: string | null;
signal: AbortSignal;
emit: AiSseEmitter;
onReady: () => void;
},
conversationId: number,
): Promise<void> {
try {
await context.executeGeneration(input);
} finally {
context.activeConversations.delete(conversationId);
}
}
export {
assertReviewImportPermissions,
confirmReviewGroup,
confirmReviewStep,
} from './ai-chat.review-confirm';
export { submitForm, submitReview } from './ai-chat.submissions.flow';
export {
a2uiReviewSubmitInfo,
a2uiSubmitInfo,
buildFormSubmitModelContent,
buildReviewSubmitModelContent,
markFormSubmittedOnMessage,
markReviewSubmittedOnMessage,
} from './ai-chat.submit-content';
export { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime';

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from '@jest/globals';
import {
a2uiReviewSubmitInfo,
a2uiSubmitInfo,
buildFormSubmitModelContent,
buildReviewSubmitModelContent,
} from './ai-chat.submit-content';
describe('a2ui submit model content', () => {
it('表单提交信息解析 submissionId 与 fieldErrors', () => {
const info = a2uiSubmitInfo({
a2uiSubmit: {
formId: 'form-1',
formTitle: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [{ field: 'phone', message: '手机号格式错误' }],
},
});
expect(info).toEqual({
formId: 'form-1',
title: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [{ field: 'phone', message: '手机号格式错误' }],
});
});
it('表单回灌内容包含提交 ID 与结构化错误', () => {
const content = buildFormSubmitModelContent({
title: '新增学生',
values: { name: '张三' },
submissionId: 'sub-1',
fieldErrors: [],
});
expect(content).toContain('sub-1');
expect(content).toContain('"name":"张三"');
expect(content).toContain('fieldErrors');
});
it('审阅提交信息解析 submissionId 与下一步建议', () => {
const info = a2uiReviewSubmitInfo({
a2uiReviewSubmit: {
reviewId: 'review-1',
reviewTitle: '批量入住导入',
resultMessage: '已导入 12 条',
submissionId: 'sub-2',
nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }],
},
});
expect(info?.submissionId).toBe('sub-2');
expect(info?.nextSteps).toHaveLength(1);
});
it('审阅回灌内容包含提交 ID 与下一步建议', () => {
const content = buildReviewSubmitModelContent({
reviewId: 'review-1',
reviewTitle: '批量入住导入',
resultMessage: '已导入 12 条',
submissionId: 'sub-2',
nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }],
});
expect(content).toContain('sub-2');
expect(content).toContain('建议录入费用');
});
});

Some files were not shown because too many files have changed in this diff Show More