Compare commits
32 Commits
b70f45fb04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db0d972633 | |||
| 8d4ebcf9c0 | |||
| 5f9566e26d | |||
| 5b5ffb5b9e | |||
| 899d2dde5b | |||
| 54b002455f | |||
| b032890b4f | |||
| f560b046a4 | |||
| 549a3ff14b | |||
| 7fdfdbc717 | |||
| edb798b752 | |||
| a436d9aa38 | |||
| bbeea440f9 | |||
| 9048816abc | |||
| 14db28afc6 | |||
| c72ff2cb8a | |||
| 24e0ecbdaf | |||
| d9c541dacc | |||
| ae88372ef8 | |||
| 259271f56c | |||
| 824c33a71c | |||
| 7f09d5271e | |||
| 7f3e30ba38 | |||
| d0ab8da01b | |||
| 6a18fd264d | |||
| 026a9f35d8 | |||
| 2e7bb81ebd | |||
| 67e14e357c | |||
| 6249fefc64 | |||
| d1c933f032 | |||
| 1a1e90c72f | |||
| ab4765cee5 |
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())),
|
||||
|
||||
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
204
apps/admin/src/components/AiChat/sseReducer.ts
Normal file
204
apps/admin/src/components/AiChat/sseReducer.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
apps/admin/src/components/AiChat/uiArtifacts.ts
Normal file
67
apps/admin/src/components/AiChat/uiArtifacts.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
? [
|
||||
|
||||
@@ -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]] },
|
||||
);
|
||||
|
||||
|
||||
@@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
||||
},
|
||||
});
|
||||
},
|
||||
[studentId, module],
|
||||
[studentId, module, modal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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="服务商">
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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} 条记录
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} ·{' '}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
() => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
||||
}
|
||||
export interface GanttOccupancy {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
studentId?: string | number;
|
||||
checkInDate: string;
|
||||
checkOutDate: string | null;
|
||||
billingStartDate?: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -171,7 +171,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
|
||||
title="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}>
|
||||
返回今天
|
||||
|
||||
@@ -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']] },
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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="备注">
|
||||
|
||||
@@ -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",
|
||||
|
||||
318
apps/server/src/agent-context/business-context.registry.ts
Normal file
318
apps/server/src/agent-context/business-context.registry.ts
Normal 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,
|
||||
);
|
||||
@@ -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('账单')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
180
apps/server/src/agent-context/business-context.service.ts
Normal file
180
apps/server/src/agent-context/business-context.service.ts
Normal 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 };
|
||||
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal file
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
apps/server/src/agent-context/business-context.types.ts
Normal file
66
apps/server/src/agent-context/business-context.types.ts
Normal 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[];
|
||||
}
|
||||
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal file
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal file
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/agent-context/index.ts
Normal file
12
apps/server/src/agent-context/index.ts
Normal 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';
|
||||
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal file
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal file
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal file
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal file
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal file
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
56
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal file
56
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '是否更新已匹配的现有记录;默认 true,false 时已匹配行跳过',
|
||||
},
|
||||
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(仅字母数字下划线,≤50),type 为业务类型。',
|
||||
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、transferDate(YYYY-MM-DD);入住记录 name/phone 或 studentNo、roomNumber、checkInDate(YYYY-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(声明业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全;生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。每个回答回合最多调用一次 start_import_wizard。
|
||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
||||
1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。
|
||||
2. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / 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。`;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/PPT):stats 统计、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,
|
||||
|
||||
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal file
109
apps/server/src/ai-chat/ai-chat.import-confirm.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
128
apps/server/src/ai-chat/ai-chat.review-confirm.ts
Normal file
128
apps/server/src/ai-chat/ai-chat.review-confirm.ts
Normal 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);
|
||||
}
|
||||
348
apps/server/src/ai-chat/ai-chat.service-base.ts
Normal file
348
apps/server/src/ai-chat/ai-chat.service-base.ts
Normal 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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
230
apps/server/src/ai-chat/ai-chat.submissions.flow.ts
Normal file
230
apps/server/src/ai-chat/ai-chat.submissions.flow.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
82
apps/server/src/ai-chat/ai-chat.submissions.runtime.ts
Normal file
82
apps/server/src/ai-chat/ai-chat.submissions.runtime.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal file
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal 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
Reference in New Issue
Block a user