Merge branch 'feat/ai-a2ui-context' into main
This commit is contained in:
@@ -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())),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
? [
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[setUnavailableDates],
|
||||
);
|
||||
|
||||
const handleClassroomChange = (classroomId: number) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
@@ -147,50 +147,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 +410,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[handlePurge, hasPermission],
|
||||
[handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +132,7 @@ 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(
|
||||
@@ -161,11 +166,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;
|
||||
|
||||
@@ -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)}>
|
||||
返回今天
|
||||
|
||||
@@ -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 = [],
|
||||
@@ -329,41 +332,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 +462,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 +520,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);
|
||||
@@ -353,7 +368,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(
|
||||
() => [
|
||||
@@ -257,7 +263,7 @@ const WalletsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[openChange, showTransactions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -342,7 +348,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 +384,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('建议录入费用');
|
||||
});
|
||||
});
|
||||
127
apps/server/src/ai-chat/ai-chat.submit-content.ts
Normal file
127
apps/server/src/ai-chat/ai-chat.submit-content.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { AiReview } from './entities/ai-review.entity';
|
||||
import type { AiChatServiceContext } from './ai-chat.types';
|
||||
|
||||
export function a2uiSubmitInfo(
|
||||
metadata: Record<string, unknown> | null,
|
||||
): {
|
||||
formId: string;
|
||||
title: string;
|
||||
values: Record<string, unknown>;
|
||||
submissionId?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
} | null {
|
||||
const submit = metadata?.a2uiSubmit;
|
||||
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
|
||||
const record = submit as Record<string, unknown>;
|
||||
const formId = typeof record.formId === 'string' ? record.formId : '';
|
||||
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>)
|
||||
: {};
|
||||
const submissionId =
|
||||
typeof record.submissionId === 'string' ? record.submissionId : undefined;
|
||||
const fieldErrors = Array.isArray(record.fieldErrors)
|
||||
? (record.fieldErrors as Array<{ field: string; message: string }>)
|
||||
: [];
|
||||
return { formId, title, values, submissionId, fieldErrors };
|
||||
}
|
||||
|
||||
export function buildFormSubmitModelContent(submit: {
|
||||
title: string;
|
||||
values: Record<string, unknown>;
|
||||
submissionId?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
}): string {
|
||||
let json: string;
|
||||
try {
|
||||
json = JSON.stringify(submit.values);
|
||||
} catch {
|
||||
json = '[无法序列化]';
|
||||
}
|
||||
const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : '';
|
||||
const fieldErrors = submit.fieldErrors?.length
|
||||
? `\nfieldErrors: ${JSON.stringify(submit.fieldErrors)}`
|
||||
: '\nfieldErrors: []';
|
||||
return `【表单提交:${submit.title}】${submissionId}\n提交值(JSON):${json.slice(0, 32 * 1024)}${fieldErrors}\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;
|
||||
submissionId?: string;
|
||||
nextSteps?: Array<{ key: string; label: 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;
|
||||
const submissionId =
|
||||
typeof record.submissionId === 'string' ? record.submissionId : undefined;
|
||||
const nextSteps = Array.isArray(record.nextSteps)
|
||||
? (record.nextSteps as Array<{ key: string; label: string }>)
|
||||
: [];
|
||||
return {
|
||||
reviewId: record.reviewId,
|
||||
reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入',
|
||||
resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成',
|
||||
submissionId,
|
||||
nextSteps,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReviewSubmitModelContent(submit: {
|
||||
reviewId: string;
|
||||
reviewTitle: string;
|
||||
resultMessage: string;
|
||||
submissionId?: string;
|
||||
nextSteps?: Array<{ key: string; label: string }>;
|
||||
}): string {
|
||||
const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : '';
|
||||
const nextSteps = submit.nextSteps?.length
|
||||
? `\n下一步建议:${submit.nextSteps.map((step) => step.label).join(';')}`
|
||||
: '';
|
||||
return `【批量导入已确认:${submit.reviewTitle}】${submissionId}\n${submit.resultMessage}${nextSteps}\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);
|
||||
}
|
||||
}
|
||||
225
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal file
225
apps/server/src/ai-chat/ai-chat.tool-actions.import.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
IMPORT_STEP_KEYS,
|
||||
type ImportStageRequest,
|
||||
type ImportStepKey,
|
||||
} from '../imports/imports.types';
|
||||
import { permittedStepKeys } from '../imports/imports.access';
|
||||
import { expandStageSheets } from '../imports/imports.mapping';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AgentToolContext } from './ai-chat.tools';
|
||||
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||||
import { AiMessage } from './entities';
|
||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||
import {
|
||||
isExcelAttachment,
|
||||
parseConfirmedMapping,
|
||||
parseConfirmedSettings,
|
||||
} from './ai-chat.import-confirm';
|
||||
|
||||
function parseAttachmentArgs(
|
||||
parsedArgs: unknown,
|
||||
): { parsedRecord: Record<string, unknown>; attachmentId: number } {
|
||||
const parsedRecord =
|
||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||
? (parsedArgs as Record<string, unknown>)
|
||||
: {};
|
||||
if (
|
||||
typeof parsedRecord.attachmentId !== 'number' ||
|
||||
!Number.isInteger(parsedRecord.attachmentId) ||
|
||||
parsedRecord.attachmentId <= 0
|
||||
) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
}
|
||||
return { parsedRecord, attachmentId: parsedRecord.attachmentId };
|
||||
}
|
||||
|
||||
async function beginImportToolRun(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
emit: AiSseEmitter,
|
||||
toolName: string,
|
||||
) {
|
||||
return startToolRun(context, messageId, call, emit, {
|
||||
toolName,
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
}
|
||||
|
||||
interface ImportToolContext {
|
||||
run: Awaited<ReturnType<typeof beginImportToolRun>>['run'];
|
||||
parsedArgs: Awaited<ReturnType<typeof beginImportToolRun>>['parsedArgs'];
|
||||
startedAt: Awaited<ReturnType<typeof beginImportToolRun>>['startedAt'];
|
||||
assistant: AiMessage;
|
||||
agentContext: AgentToolContext;
|
||||
context: AiChatServiceContext;
|
||||
call: ModelToolCall;
|
||||
emit: AiSseEmitter;
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
async function runImportTool(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
agentContext: AgentToolContext,
|
||||
emit: AiSseEmitter,
|
||||
toolName: string,
|
||||
handler: (tool: ImportToolContext) => Promise<string>,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await beginImportToolRun(
|
||||
context,
|
||||
messageId,
|
||||
call,
|
||||
emit,
|
||||
toolName,
|
||||
);
|
||||
try {
|
||||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||||
if (!assistant) throw new Error('assistant message missing');
|
||||
return await handler({ run, parsedArgs, startedAt, assistant, agentContext, context, call, emit, messageId });
|
||||
} catch (error) {
|
||||
const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`;
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'failed',
|
||||
summary,
|
||||
error: summary,
|
||||
}, emit);
|
||||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||||
}
|
||||
}
|
||||
|
||||
type ImportToolExecutor = (
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
agentContext: AgentToolContext,
|
||||
emit: AiSseEmitter,
|
||||
) => Promise<string>;
|
||||
|
||||
function makeImportToolExecutor(
|
||||
toolName: 'start_import_wizard',
|
||||
handler: (tool: ImportToolContext) => Promise<string>,
|
||||
): ImportToolExecutor {
|
||||
return (context, messageId, call, agentContext, emit) =>
|
||||
runImportTool(context, messageId, call, agentContext, emit, toolName, handler);
|
||||
}
|
||||
|
||||
export const executeStartImportWizard = makeImportToolExecutor(
|
||||
'start_import_wizard',
|
||||
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
||||
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
||||
const stages = Array.isArray(parsedRecord.stages)
|
||||
? (parsedRecord.stages as ImportStageRequest[])
|
||||
: [];
|
||||
if (stages.length === 0) throw new Error('缺少 stages 参数');
|
||||
for (const stage of stages) {
|
||||
if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {
|
||||
throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`);
|
||||
}
|
||||
if (expandStageSheets(stage).length === 0) {
|
||||
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet/sheets,请指定 Excel 中对应的表名`);
|
||||
}
|
||||
if (
|
||||
stage.headerRow !== undefined &&
|
||||
(!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000)
|
||||
) {
|
||||
throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`);
|
||||
}
|
||||
}
|
||||
const mapping = parseConfirmedMapping(parsedRecord.mapping);
|
||||
const settings = parseConfirmedSettings(parsedRecord);
|
||||
if (!context.importsService) throw new Error('导入向导服务未配置');
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
const detail = await context.importsService.createRun(
|
||||
{
|
||||
id: ac.userId,
|
||||
permissions: [...ac.permissions],
|
||||
isSuperAdmin: ac.isSuperAdmin,
|
||||
},
|
||||
'ai',
|
||||
{
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
buffer,
|
||||
},
|
||||
assistant.conversationId,
|
||||
stages,
|
||||
mapping,
|
||||
settings,
|
||||
);
|
||||
const wizard = compactImportWizard(detail);
|
||||
assistant.metadata = {
|
||||
...assistant.metadata,
|
||||
a2uiImportWizard: wizard,
|
||||
};
|
||||
await context.messages.save(assistant);
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'success',
|
||||
summary: `已生成导入向导:${detail.steps
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => step.label)
|
||||
.join('、')}`,
|
||||
}, emit);
|
||||
emit('ui.import_wizard', { messageId, wizard });
|
||||
emit('ui.artifact', {
|
||||
messageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'import_wizard',
|
||||
id: `wizard-${detail.id}`,
|
||||
status: 'pending',
|
||||
messageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: wizard,
|
||||
}),
|
||||
});
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
runId: detail.id,
|
||||
steps: detail.steps
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })),
|
||||
permittedSteps: permittedStepKeys({
|
||||
id: ac.userId,
|
||||
permissions: [...ac.permissions],
|
||||
isSuperAdmin: ac.isSuperAdmin,
|
||||
}),
|
||||
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
||||
});
|
||||
});
|
||||
|
||||
export function compactImportWizard(detail: any): {
|
||||
runId: string;
|
||||
fileName: string;
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
suggestedStepKey: string | null;
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
}>;
|
||||
steps: Array<{ stepKey: string; label: string; sheets: string[]; status: string }>;
|
||||
} {
|
||||
return {
|
||||
runId: detail.id,
|
||||
fileName: detail.fileName,
|
||||
sheets: detail.sheets.map((sheet: any) => ({
|
||||
name: sheet.name,
|
||||
suggestedStepKey: sheet.suggestedStepKey,
|
||||
headers: sheet.headers,
|
||||
rowCount: sheet.rowCount,
|
||||
})),
|
||||
steps: detail.steps.map((step: any) => ({
|
||||
stepKey: step.stepKey,
|
||||
label: step.label,
|
||||
sheets: step.sheets,
|
||||
status: step.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,133 +1,7 @@
|
||||
import { AiReview } from './entities/ai-review.entity';
|
||||
import { IMPORT_STEP_KEYS, type ImportStageRequest } from '../imports/imports.types';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AgentToolContext } from './ai-chat.tools';
|
||||
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||
export { executeOfficeAnalyze, buildOfficeCliArgs } from './ai-chat.tool-office';
|
||||
|
||||
export async function executeStartImportWizard(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
agentContext: AgentToolContext,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'start_import_wizard',
|
||||
skillKey: null,
|
||||
argumentsData: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const assistant = await context.messages.findOne({ where: { id: messageId } });
|
||||
if (!assistant) throw new Error('assistant message missing');
|
||||
const parsedRecord =
|
||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||
? (parsedArgs as Record<string, unknown>)
|
||||
: {};
|
||||
const attachmentId =
|
||||
typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined;
|
||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
||||
throw new Error('缺少附件 attachmentId');
|
||||
}
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [
|
||||
attachmentId as number,
|
||||
]);
|
||||
const isExcel =
|
||||
attachment.mimeType.includes('spreadsheetml') ||
|
||||
attachment.mimeType.includes('excel') ||
|
||||
attachment.mimeType.includes('csv') ||
|
||||
/\.(xlsx|csv)$/i.test(attachment.originalName);
|
||||
if (!isExcel) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
||||
const stages = Array.isArray(parsedRecord.stages)
|
||||
? (parsedRecord.stages as ImportStageRequest[])
|
||||
: [];
|
||||
if (stages.length === 0) throw new Error('缺少 stages 参数');
|
||||
for (const stage of stages) {
|
||||
if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {
|
||||
throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`);
|
||||
}
|
||||
if (!stage.sheet || !String(stage.sheet).trim()) {
|
||||
throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`);
|
||||
}
|
||||
}
|
||||
if (!context.importsService) throw new Error('导入向导服务未配置');
|
||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
||||
const detail = await context.importsService.createRun(
|
||||
{
|
||||
id: agentContext.userId,
|
||||
permissions: [...agentContext.permissions],
|
||||
isSuperAdmin: agentContext.isSuperAdmin,
|
||||
},
|
||||
'ai',
|
||||
{
|
||||
originalName: attachment.originalName,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
buffer,
|
||||
},
|
||||
assistant.conversationId,
|
||||
stages,
|
||||
);
|
||||
const wizard = compactImportWizard(detail);
|
||||
assistant.metadata = {
|
||||
...assistant.metadata,
|
||||
a2uiImportWizard: wizard,
|
||||
};
|
||||
await context.messages.save(assistant);
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, {
|
||||
status: 'success',
|
||||
summary: `已生成导入向导:${detail.steps
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => step.label)
|
||||
.join('、')}`,
|
||||
}, emit);
|
||||
emit('ui.import_wizard', { messageId, wizard });
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
runId: detail.id,
|
||||
steps: detail.steps
|
||||
.filter((step) => step.status !== 'skipped')
|
||||
.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })),
|
||||
message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库',
|
||||
});
|
||||
} catch (error) {
|
||||
const summary = error instanceof Error ? error.message.slice(0, 100) : '生成导入向导失败';
|
||||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary }, emit);
|
||||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||||
}
|
||||
}
|
||||
|
||||
export function compactImportWizard(detail: any): {
|
||||
runId: string;
|
||||
fileName: string;
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
suggestedStepKey: string | null;
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
}>;
|
||||
steps: Array<{ stepKey: string; label: string; sheets: string[]; status: string }>;
|
||||
} {
|
||||
return {
|
||||
runId: detail.id,
|
||||
fileName: detail.fileName,
|
||||
sheets: detail.sheets.map((sheet: any) => ({
|
||||
name: sheet.name,
|
||||
suggestedStepKey: sheet.suggestedStepKey,
|
||||
headers: sheet.headers,
|
||||
rowCount: sheet.rowCount,
|
||||
})),
|
||||
steps: detail.steps.map((step: any) => ({
|
||||
stepKey: step.stepKey,
|
||||
label: step.label,
|
||||
sheets: step.sheets,
|
||||
status: step.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeRenderForm(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
@@ -147,6 +21,39 @@ export async function executeRenderForm(
|
||||
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
||||
parsedArgs,
|
||||
);
|
||||
const expiredForms = await context.formService.expirePreviousForms(
|
||||
userId,
|
||||
assistant.conversationId,
|
||||
form.id,
|
||||
);
|
||||
await Promise.all(
|
||||
expiredForms.map(async (expired) => {
|
||||
const oldAssistant = await context.messages.findOne({
|
||||
where: { id: expired.assistantMessageId, conversationId: assistant.conversationId },
|
||||
});
|
||||
const oldA2ui = oldAssistant?.metadata?.a2uiForm;
|
||||
if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) {
|
||||
oldAssistant.metadata = {
|
||||
...oldAssistant.metadata,
|
||||
a2uiForm: context.formService.serialize(expired),
|
||||
};
|
||||
await context.messages.save(oldAssistant);
|
||||
}
|
||||
const expiredPayload = context.formService.serialize(expired);
|
||||
emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload });
|
||||
emit('ui.artifact', {
|
||||
messageId: expired.assistantMessageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'form',
|
||||
id: expired.id,
|
||||
status: 'expired',
|
||||
messageId: expired.assistantMessageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: expiredPayload,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
assistant.metadata = {
|
||||
...assistant.metadata,
|
||||
a2uiForm: context.formService.serialize(form),
|
||||
@@ -158,6 +65,17 @@ export async function executeRenderForm(
|
||||
messageId,
|
||||
form: context.formService.serialize(form),
|
||||
});
|
||||
emit('ui.artifact', {
|
||||
messageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'form',
|
||||
id: form.id,
|
||||
status: form.status === 'submitted' ? 'submitted' : 'pending',
|
||||
messageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: context.formService.serialize(form),
|
||||
}),
|
||||
});
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
formId: form.id,
|
||||
@@ -246,6 +164,17 @@ export async function executeRenderReview(
|
||||
messageId: expired.assistantMessageId,
|
||||
review: context.reviewService.serialize(expired),
|
||||
});
|
||||
emit('ui.artifact', {
|
||||
messageId: expired.assistantMessageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'review',
|
||||
id: expired.id,
|
||||
status: expired.status === 'submitted' ? 'submitted' : 'expired',
|
||||
messageId: expired.assistantMessageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: context.reviewService.serialize(expired),
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
assistant.metadata = {
|
||||
@@ -259,6 +188,17 @@ export async function executeRenderReview(
|
||||
messageId,
|
||||
review: context.reviewService.serialize(review),
|
||||
});
|
||||
emit('ui.artifact', {
|
||||
messageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'review',
|
||||
id: review.id,
|
||||
status: review.status === 'submitted' ? 'submitted' : 'pending',
|
||||
messageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: context.reviewService.serialize(review),
|
||||
}),
|
||||
});
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
reviewId: review.id,
|
||||
@@ -306,6 +246,17 @@ export async function executeRenderChart(
|
||||
messageId,
|
||||
chart: context.chartService.serialize(chart),
|
||||
});
|
||||
emit('ui.artifact', {
|
||||
messageId,
|
||||
artifact: buildA2uiArtifact({
|
||||
type: 'chart',
|
||||
id: chart.id,
|
||||
status: 'pending',
|
||||
messageId,
|
||||
conversationId: assistant.conversationId,
|
||||
payload: context.chartService.serialize(chart),
|
||||
}),
|
||||
});
|
||||
return JSON.stringify({
|
||||
status: 'success',
|
||||
chartId: chart.id,
|
||||
@@ -316,3 +267,8 @@ export async function executeRenderChart(
|
||||
return JSON.stringify({ status: 'failed', error: '图表参数无效' });
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
compactImportWizard,
|
||||
executeStartImportWizard,
|
||||
} from './ai-chat.tool-actions.import';
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { MAX_SUMMARY_CHARS } from './ai-chat.constants';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||
|
||||
export async function executeOfficeAnalyze(
|
||||
context: AiChatServiceContext,
|
||||
messageId: number,
|
||||
call: ModelToolCall,
|
||||
userId: number,
|
||||
emit: AiSseEmitter,
|
||||
): Promise<string> {
|
||||
if (!context.officeCli) {
|
||||
return JSON.stringify({ status: 'failed', error: 'OfficeCli 未配置' });
|
||||
}
|
||||
const parsedArgs = context.parseToolArguments(call.arguments);
|
||||
const args =
|
||||
parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
|
||||
? (parsedArgs as Record<string, unknown>)
|
||||
: {};
|
||||
const action = typeof args.action === 'string' ? args.action : '';
|
||||
const validActions = new Set(['stats', 'outline', 'text', 'get', 'query', 'issues']);
|
||||
if (!validActions.has(action)) {
|
||||
return JSON.stringify({ status: 'failed', error: 'office_analyze 参数无效' });
|
||||
}
|
||||
|
||||
const { run, startedAt } = await startToolRun(context, messageId, call, emit, {
|
||||
toolName: 'office_analyze',
|
||||
skillKey: null,
|
||||
argumentsData: context.safeStructured(args) as Record<string, unknown> | null,
|
||||
parsedArgs,
|
||||
});
|
||||
|
||||
try {
|
||||
let attachmentId = Number(args.attachmentId);
|
||||
if (!Number.isInteger(attachmentId) || attachmentId <= 0) {
|
||||
const assistant = await context.messages.findOne({
|
||||
where: { id: messageId },
|
||||
relations: { replyToMessage: { attachments: true } },
|
||||
});
|
||||
const officeAttachment = (assistant?.replyToMessage?.attachments ?? []).find(
|
||||
(item) =>
|
||||
item.mimeType?.includes('spreadsheetml') ||
|
||||
item.mimeType?.includes('wordprocessingml') ||
|
||||
item.mimeType?.includes('presentationml'),
|
||||
);
|
||||
if (!officeAttachment) throw new Error('未指定附件且当前消息没有 Office 附件');
|
||||
attachmentId = officeAttachment.id;
|
||||
}
|
||||
const [attachment] = await context.attachmentService.requireReadyOwned(userId, [attachmentId]);
|
||||
if (!attachment) throw new Error('附件不存在');
|
||||
const mimeType = attachment.mimeType ?? '';
|
||||
const isOffice =
|
||||
mimeType.includes('spreadsheetml') ||
|
||||
mimeType.includes('wordprocessingml') ||
|
||||
mimeType.includes('presentationml');
|
||||
if (!isOffice) throw new Error('该附件不是 Office 文档');
|
||||
const filePath = context.attachmentService.storagePathFor(attachment);
|
||||
|
||||
const cliArgs = buildOfficeCliArgs(action, filePath, args);
|
||||
const result = await context.officeCli.run(cliArgs);
|
||||
if (!result.success) {
|
||||
const cliError = context.redactText(String(result.error ?? 'OfficeCli 分析失败')).slice(
|
||||
0,
|
||||
MAX_SUMMARY_CHARS,
|
||||
);
|
||||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: cliError, error: cliError }, emit);
|
||||
return JSON.stringify({ status: 'failed', error: 'OfficeCli 分析失败' });
|
||||
}
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(result.data);
|
||||
} catch {
|
||||
payload = '{}';
|
||||
}
|
||||
const MAX_OFFICE_RESULT_CHARS = 96 * 1024;
|
||||
let truncated = false;
|
||||
if (payload.length > MAX_OFFICE_RESULT_CHARS) {
|
||||
truncated = true;
|
||||
payload = `${payload.slice(0, MAX_OFFICE_RESULT_CHARS)}\n\n[结果过大已截断,请缩小读取范围]`;
|
||||
}
|
||||
let parsedData: unknown;
|
||||
try {
|
||||
parsedData = JSON.parse(payload);
|
||||
} catch {
|
||||
parsedData = { raw: payload.slice(0, 4000) };
|
||||
}
|
||||
|
||||
await finishToolRun(context, run, call, startedAt, { status: 'success', summary: context.summarize(result.data) }, emit);
|
||||
return JSON.stringify({ status: 'success', data: parsedData, truncated });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const failureSummary = context.redactText(errorMessage).slice(0, MAX_SUMMARY_CHARS);
|
||||
await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: failureSummary, error: failureSummary }, emit);
|
||||
return JSON.stringify({ status: 'failed', error: run.resultSummary });
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOfficeCliArgs(
|
||||
action: string,
|
||||
filePath: string,
|
||||
args: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (action === 'get') {
|
||||
const path = typeof args.path === 'string' ? args.path.slice(0, 200) : '';
|
||||
if (!path.startsWith('/') || path.includes('..')) {
|
||||
throw new Error('office_analyze 路径无效');
|
||||
}
|
||||
return ['get', filePath, path, '--json'];
|
||||
}
|
||||
if (action === 'query') {
|
||||
const selector = typeof args.selector === 'string' ? args.selector.slice(0, 200) : '';
|
||||
if (!selector) throw new Error('office_analyze 缺少 selector');
|
||||
return ['query', filePath, selector, '--json'];
|
||||
}
|
||||
if (action === 'text') {
|
||||
const extra: string[] = [];
|
||||
const maxLines = Number(args.maxLines);
|
||||
if (Number.isInteger(maxLines) && maxLines >= 1 && maxLines <= 200) {
|
||||
extra.push('--max-lines', String(maxLines));
|
||||
}
|
||||
const startRow = Number(args.startRow);
|
||||
if (Number.isInteger(startRow) && startRow > 1) {
|
||||
extra.push('--start', String(startRow));
|
||||
}
|
||||
return ['view', filePath, 'text', '--json', ...extra];
|
||||
}
|
||||
return ['view', filePath, action, '--json'];
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';
|
||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||
import type { AiToolRun } from './entities';
|
||||
import {
|
||||
executeOfficeAnalyze,
|
||||
executeRenderChart,
|
||||
executeRenderForm,
|
||||
executeRenderReview,
|
||||
executeStartImportWizard,
|
||||
} from './ai-chat.tool-actions';
|
||||
|
||||
@@ -93,18 +91,9 @@ export async function executeTool(
|
||||
if (call.name === 'start_import_wizard') {
|
||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||
}
|
||||
if (call.name === 'render_review') {
|
||||
if (reviewSubmitted) {
|
||||
return denyTool(context, messageId, call, 'render_review', '导入已确认,无需再次生成预览', '导入已确认', emit);
|
||||
}
|
||||
return executeRenderReview(context, messageId, call, userId, emit);
|
||||
}
|
||||
if (call.name === 'render_chart') {
|
||||
return executeRenderChart(context, messageId, call, emit);
|
||||
}
|
||||
if (call.name === 'office_analyze') {
|
||||
return executeOfficeAnalyze(context, messageId, call, userId, emit);
|
||||
}
|
||||
if ((call.name === 'create_student' || call.name === 'update_students') && !allowWriteTools) {
|
||||
return denyWriteTool(context, messageId, call, emit);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ 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 type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||
import { AiModelStreamService } from './ai-model-stream.service';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import {
|
||||
AiConversation,
|
||||
AiMessage,
|
||||
@@ -23,9 +24,6 @@ import {
|
||||
|
||||
export {
|
||||
DEFAULT_TITLE,
|
||||
MAX_ATTACHMENT_TEXT_CHARS,
|
||||
MAX_CONTEXT_CHARS,
|
||||
MAX_FOCUS_CONTENT_CHARS,
|
||||
MAX_GENERATED_CHARS,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
MAX_SUMMARY_CHARS,
|
||||
@@ -96,8 +94,9 @@ export interface AiChatServiceContext {
|
||||
readonly abilityFactory: CaslAbilityFactory;
|
||||
readonly authorization: AuthorizationService;
|
||||
readonly excelReader?: AiExcelReaderService;
|
||||
readonly officeCli?: OfficeCliService;
|
||||
readonly importsService?: ImportsService;
|
||||
readonly opLog?: OperationLogsService;
|
||||
readonly a2uiSubmissions?: A2uiSubmissionsService;
|
||||
listSkills(user: AuthenticatedUser): ReturnType<AgentToolExecutor['listSkills']>;
|
||||
serializeMessage(message: AiMessage): Record<string, unknown>;
|
||||
redactText(value: string): string;
|
||||
@@ -117,11 +116,18 @@ export interface AiChatServiceContext {
|
||||
reviewTitle: string;
|
||||
resultMessage: string;
|
||||
} | null;
|
||||
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string;
|
||||
buildFormSubmitModelContent(submit: {
|
||||
title: string;
|
||||
values: Record<string, unknown>;
|
||||
submissionId?: string;
|
||||
fieldErrors?: Array<{ field: string; message: string }>;
|
||||
}): string;
|
||||
buildReviewSubmitModelContent(submit: {
|
||||
reviewId: string;
|
||||
reviewTitle: string;
|
||||
resultMessage: string;
|
||||
submissionId?: string;
|
||||
nextSteps?: Array<{ key: string; label: string }>;
|
||||
}): string;
|
||||
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void>;
|
||||
markReviewSubmittedOnMessage(
|
||||
@@ -178,6 +184,7 @@ export type AiSseEventName =
|
||||
| 'ui.form'
|
||||
| 'ui.review'
|
||||
| 'ui.chart'
|
||||
| 'ui.artifact'
|
||||
| 'ui.import_wizard'
|
||||
| 'attachment.processed'
|
||||
| 'message.completed'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback';
|
||||
|
||||
export interface ExcelSheetInfo {
|
||||
name: string;
|
||||
@@ -38,10 +38,7 @@ export class AiExcelReaderService {
|
||||
}
|
||||
|
||||
/** Sheet list + row counts + a short sample, small enough for prompts. */
|
||||
async overview(
|
||||
buffer: Buffer,
|
||||
sampleRows = 12,
|
||||
): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const info = sheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
@@ -51,12 +48,7 @@ export class AiExcelReaderService {
|
||||
const lines: string[] = [];
|
||||
for (const sheet of sheets) {
|
||||
lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`);
|
||||
for (const row of sheet.rows.slice(0, sampleRows)) {
|
||||
lines.push(row.join('\t'));
|
||||
}
|
||||
if (sheet.rows.length > sampleRows) {
|
||||
lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`);
|
||||
}
|
||||
for (const row of sheet.rows) lines.push(row.join('\t'));
|
||||
}
|
||||
return { sheets: info, text: lines.join('\n') };
|
||||
}
|
||||
@@ -80,9 +72,9 @@ export class AiExcelReaderService {
|
||||
return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
|
||||
}
|
||||
const from = Math.max(0, startRow - 1);
|
||||
const limit = Math.min(rowCount, 200);
|
||||
const limit = rowCount;
|
||||
const slice = sheet.rows.slice(from, from + limit);
|
||||
const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
|
||||
const rows = slice.map((row) => row.slice(0, maxColumns));
|
||||
return {
|
||||
sheet: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
@@ -108,121 +100,19 @@ export class AiExcelReaderService {
|
||||
}
|
||||
|
||||
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const readEntry = async (name: string): Promise<string | null> => {
|
||||
const entry = zip.file(name);
|
||||
return entry ? entry.async('string') : null;
|
||||
};
|
||||
const workbookXml = await readEntry('xl/workbook.xml');
|
||||
if (!workbookXml) throw new Error('workbook.xml missing');
|
||||
const stripPrefixes = (value: string): string =>
|
||||
value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
|
||||
const relTargets = new Map<string, string>();
|
||||
for (const match of relsXml.matchAll(
|
||||
/<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
|
||||
)) {
|
||||
const target = match[2].replace(/^\/+/, '');
|
||||
relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
|
||||
}
|
||||
|
||||
const sharedStrings = await this.parseSharedStringsFallback(readEntry);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
const cleanWorkbook = stripPrefixes(workbookXml);
|
||||
for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
|
||||
const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
|
||||
const name = tag.match(/\bname="([^"]+)"/)?.[1];
|
||||
const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
|
||||
if (!name || !rid) continue;
|
||||
const target = relTargets.get(rid);
|
||||
const sheetXml = target ? await readEntry(target) : null;
|
||||
if (!sheetXml) continue;
|
||||
sheets.push({
|
||||
name: this.unescapeXml(name),
|
||||
rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
|
||||
});
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
private async parseSharedStringsFallback(
|
||||
readEntry: (name: string) => Promise<string | null>,
|
||||
): Promise<string[]> {
|
||||
const xml = await readEntry('xl/sharedStrings.xml');
|
||||
if (!xml) return [];
|
||||
const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
const strings: string[] = [];
|
||||
for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
|
||||
const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
strings.push(texts.join(''));
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
|
||||
const cells = new Map<number, string>();
|
||||
let maxColumn = -1;
|
||||
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
|
||||
const attrs = cellMatch[1];
|
||||
const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
|
||||
const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
|
||||
const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
|
||||
const body = cellMatch[2] ?? '';
|
||||
let value = '';
|
||||
if (type === 's') {
|
||||
const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
|
||||
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||
} else if (type === 'inlineStr') {
|
||||
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
|
||||
this.unescapeXml(part[1]),
|
||||
);
|
||||
value = texts.join('');
|
||||
} else {
|
||||
value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
|
||||
if (type === 'b') value = value === '1' ? 'true' : 'false';
|
||||
}
|
||||
if (column >= 0) {
|
||||
cells.set(column, value);
|
||||
maxColumn = Math.max(maxColumn, column);
|
||||
}
|
||||
}
|
||||
if (maxColumn < 0) continue;
|
||||
const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
|
||||
if (values.every((value) => value === '')) continue;
|
||||
rows.push(values);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private columnIndex(letters: string): number {
|
||||
let index = 0;
|
||||
for (const char of letters.toUpperCase()) {
|
||||
index = index * 26 + (char.charCodeAt(0) - 64);
|
||||
}
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
private unescapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
|
||||
String.fromCodePoint(Number.parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
|
||||
const sheets = await readXlsxSheetsFallback(buffer);
|
||||
return sheets.map((sheet) => ({ name: sheet.name, rows: sheet.rows }));
|
||||
}
|
||||
|
||||
private stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { AiFormService } from './ai-form.service';
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}) {
|
||||
@@ -140,6 +141,35 @@ describe('AiFormService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expirePreviousForms', () => {
|
||||
it('过期同会话其他 pending 表单并返回过期记录', async () => {
|
||||
const pending = [
|
||||
{ id: 'form-a', userId: 7, conversationId: 3, status: 'pending' },
|
||||
{ id: 'form-b', userId: 7, conversationId: 3, status: 'pending' },
|
||||
];
|
||||
const { service, forms } = createService({
|
||||
find: jest.fn().mockResolvedValue(pending),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
});
|
||||
const expired = await service.expirePreviousForms(7, 3, 'form-b');
|
||||
expect(expired.map((item) => item.id)).toEqual(['form-a']);
|
||||
expect(expired[0].status).toBe('expired');
|
||||
expect(forms.update).toHaveBeenCalledWith(
|
||||
{ id: In(['form-a']) },
|
||||
{ status: 'expired' },
|
||||
);
|
||||
});
|
||||
|
||||
it('没有其他 pending 表单时返回空数组且不更新', async () => {
|
||||
const { service, forms } = createService({
|
||||
find: jest.fn().mockResolvedValue([{ id: 'form-b' }]),
|
||||
update: jest.fn(),
|
||||
});
|
||||
await expect(service.expirePreviousForms(7, 3, 'form-b')).resolves.toEqual([]);
|
||||
expect(forms.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateValues', () => {
|
||||
const form = {
|
||||
fieldsJson: JSON.stringify(validSchema.fields),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
import { AiForm, type AiFormField } from './entities/ai-form.entity';
|
||||
|
||||
@@ -108,10 +108,28 @@ export class AiFormService {
|
||||
|
||||
async findOwnedPending(formId: string, userId: number): Promise<AiForm> {
|
||||
const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } });
|
||||
if (!form) throw new NotFoundException('表单不存在或已提交');
|
||||
if (!form) throw new NotFoundException('表单不存在、已提交或已失效');
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过期同会话其他 pending 表单(新表单渲染后旧表单不可再提交)。
|
||||
*/
|
||||
async expirePreviousForms(
|
||||
userId: number,
|
||||
conversationId: number,
|
||||
exceptFormId: string,
|
||||
): Promise<AiForm[]> {
|
||||
const pending = await this.forms.find({
|
||||
where: { userId, conversationId, status: 'pending' },
|
||||
});
|
||||
const expired = pending.filter((form) => form.id !== exceptFormId);
|
||||
if (expired.length === 0) return [];
|
||||
const ids = expired.map((form) => form.id);
|
||||
await this.forms.update({ id: In(ids) }, { status: 'expired' });
|
||||
return expired.map((form) => ({ ...form, status: 'expired' as const }));
|
||||
}
|
||||
|
||||
async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
|
||||
form.status = 'submitted';
|
||||
form.submittedValuesJson = JSON.stringify(values);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as allEntities from '../entities';
|
||||
import { Bed } from '../entities/bed.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
import { AiReview } from './entities/ai-review.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { AiReviewService } from './ai-review.service';
|
||||
import type { ExcelSheetRows } from './ai-excel-reader.service';
|
||||
|
||||
@@ -636,4 +625,3 @@ describe('AiReviewService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次,
|
||||
* 重复提交返回既有结果,避免双击/重试导致二次写入或二次生成。
|
||||
*/
|
||||
@Entity('ai_a2ui_submissions')
|
||||
@Index('uk_ai_a2ui_submissions_artifact_client', ['artifactId', 'clientRequestId'], {
|
||||
unique: true,
|
||||
})
|
||||
export class AiA2uiSubmission {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'artifact_id', type: 'varchar', length: 100 })
|
||||
artifactId: string;
|
||||
|
||||
@Column({ name: 'client_request_id', type: 'varchar', length: 36 })
|
||||
clientRequestId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'result_json', type: 'text', nullable: true })
|
||||
resultJson: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { AiMessage } from './ai-message.entity';
|
||||
|
||||
export type AiFormStatus = 'pending' | 'submitted';
|
||||
export type AiFormStatus = 'pending' | 'submitted' | 'expired';
|
||||
|
||||
export interface AiFormField {
|
||||
name: string;
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './ai-tool-run.entity';
|
||||
export * from './ai-attachment.entity';
|
||||
export * from './ai-form.entity';
|
||||
export * from './ai-review.entity';
|
||||
export * from './ai-a2ui-submission.entity';
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
|
||||
describe('OfficeCliService', () => {
|
||||
it('prefers the npm-bundled binary when @officecli/officecli is installed', async () => {
|
||||
const service = new OfficeCliService();
|
||||
const resolveBinary = (
|
||||
service as unknown as { resolveBinary(): Promise<string> }
|
||||
).resolveBinary.bind(service);
|
||||
const resolved = await resolveBinary();
|
||||
expect(resolved).toContain('@officecli/officecli');
|
||||
});
|
||||
|
||||
it('returns structured results from a real view call', async () => {
|
||||
const service = new OfficeCliService();
|
||||
const result = await service.view(process.execPath, 'outline');
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user