Compare commits
7 Commits
c38c508547
...
bd55e0a061
| Author | SHA1 | Date | |
|---|---|---|---|
| bd55e0a061 | |||
| 3a1ed717d8 | |||
| 216a20ffaf | |||
| 50c44e4410 | |||
| f07ffdc64c | |||
| ce1dcc07ea | |||
| 75c42f52a1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,3 +55,7 @@ apps/admin/dist/
|
||||
.claude/
|
||||
.omp/
|
||||
.superpowers/
|
||||
|
||||
# 测试截图产物
|
||||
.vitest-attachments/
|
||||
**/__screenshots__/
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/x": "^2.8.0",
|
||||
"@ant-design/x-card": "^2.9.0",
|
||||
"@ant-design/x-markdown": "^2.8.0",
|
||||
"@ant-design/x-sdk": "^2.8.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -29,7 +30,8 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"tslib": "^2.8.1"
|
||||
"tslib": "^2.8.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gongxue/typescript-config": "*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import MainLayout from './layouts/MainLayout';
|
||||
import PermissionRoute from './components/PermissionRoute';
|
||||
import DefaultRoute from './components/DefaultRoute';
|
||||
import AppMessageBridge from './ui/AppMessageBridge';
|
||||
import { useUserStore } from './store/user/userStore';
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/Login'));
|
||||
const DashboardPage = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -42,7 +43,7 @@ const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
const AiConfigPage = lazy(() => import('./pages/AiConfig'));
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore((state) => state.token);
|
||||
return token ? <>{children}</> : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { clearPermissions } from '../auth/permission-store';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -7,7 +8,7 @@ const instance = axios.create({
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@@ -20,9 +21,8 @@ instance.interceptors.response.use(
|
||||
const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login';
|
||||
|
||||
if (err.response?.status === 401 && !isLoginRequest) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
clearPermissions();
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
if (err.response?.status === 403) {
|
||||
|
||||
@@ -2,12 +2,7 @@ import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import PermissionButton from '../components/PermissionButton';
|
||||
import {
|
||||
beginPermissionVerification,
|
||||
clearPermissions,
|
||||
readPermissionState,
|
||||
writePermissions,
|
||||
} from './permission-store';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
@@ -27,18 +22,25 @@ async function renderPermissionButton() {
|
||||
});
|
||||
}
|
||||
|
||||
function readPermissionState() {
|
||||
return {
|
||||
permissions: usePermissionStore.getState().permissions,
|
||||
status: usePermissionStore.getState().status,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
clearPermissions();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
});
|
||||
|
||||
describe('permission state', () => {
|
||||
it('ignores cached localStorage permissions until profile verification succeeds', async () => {
|
||||
localStorage.setItem('permissions', JSON.stringify(['student:edit']));
|
||||
beginPermissionVerification();
|
||||
usePermissionStore.getState().beginPermissionVerification();
|
||||
|
||||
expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' });
|
||||
await renderPermissionButton();
|
||||
@@ -46,20 +48,20 @@ describe('permission state', () => {
|
||||
});
|
||||
|
||||
it('renders permission actions only after verified permissions are written', async () => {
|
||||
beginPermissionVerification();
|
||||
usePermissionStore.getState().beginPermissionVerification();
|
||||
await renderPermissionButton();
|
||||
expect(container?.textContent).not.toContain('编辑学生');
|
||||
|
||||
await act(async () => writePermissions(['student:edit']));
|
||||
await act(async () => usePermissionStore.getState().writePermissions(['student:edit']));
|
||||
expect(container?.textContent).toContain('编辑学生');
|
||||
});
|
||||
|
||||
it('stays fail-closed while profile verification is retried after a failure', async () => {
|
||||
writePermissions(['student:edit']);
|
||||
beginPermissionVerification();
|
||||
it('keeps verified permissions while profile verification refreshes in the background', async () => {
|
||||
usePermissionStore.getState().writePermissions(['student:edit']);
|
||||
usePermissionStore.getState().beginPermissionVerification();
|
||||
|
||||
expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' });
|
||||
expect(readPermissionState()).toEqual({ permissions: ['student:edit'], status: 'ready' });
|
||||
await renderPermissionButton();
|
||||
expect(container?.textContent).not.toContain('编辑学生');
|
||||
expect(container?.textContent).toContain('编辑学生');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated';
|
||||
|
||||
export type PermissionStatus = 'unknown' | 'loading' | 'ready';
|
||||
|
||||
export interface PermissionState {
|
||||
permissions: string[];
|
||||
status: PermissionStatus;
|
||||
}
|
||||
|
||||
let permissionState: PermissionState = { permissions: [], status: 'unknown' };
|
||||
|
||||
function notifyPermissionStateChanged(): void {
|
||||
window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT));
|
||||
}
|
||||
|
||||
export function readPermissionState(): PermissionState {
|
||||
return permissionState;
|
||||
}
|
||||
|
||||
export function readPermissions(): string[] {
|
||||
return permissionState.status === 'ready' ? permissionState.permissions : [];
|
||||
}
|
||||
|
||||
export function beginPermissionVerification(): void {
|
||||
permissionState = { permissions: [], status: 'loading' };
|
||||
notifyPermissionStateChanged();
|
||||
}
|
||||
|
||||
export function writePermissions(permissions: string[]): void {
|
||||
const uniquePermissions = [...new Set(permissions)];
|
||||
localStorage.setItem('permissions', JSON.stringify(uniquePermissions));
|
||||
permissionState = { permissions: uniquePermissions, status: 'ready' };
|
||||
notifyPermissionStateChanged();
|
||||
}
|
||||
|
||||
export function clearPermissions(status: PermissionStatus = 'unknown'): void {
|
||||
localStorage.removeItem('permissions');
|
||||
permissionState = { permissions: [], status };
|
||||
notifyPermissionStateChanged();
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CheckSquareOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ArrowRightOutlined,
|
||||
LoadingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
Conversations,
|
||||
Prompts,
|
||||
Sender,
|
||||
SenderSwitch,
|
||||
Welcome,
|
||||
} from '@ant-design/x';
|
||||
import type {
|
||||
@@ -23,9 +28,21 @@ import type {
|
||||
} from '@ant-design/x';
|
||||
import type { Attachment } from '@ant-design/x/es/attachments';
|
||||
import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk';
|
||||
import { Button, Drawer, Dropdown, Grid, Input, Modal, Spin, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Grid,
|
||||
Input,
|
||||
Modal,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { MenuProps, UploadFile, UploadProps } from 'antd';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useSettingsStore } from '../../store/settings/settingsStore';
|
||||
import { aiChatApi, conversationStreamUrl } from './api';
|
||||
import { AiMessageContent } from './AiMessageContent';
|
||||
import { mapHistoryMessage } from './message-mappers';
|
||||
@@ -36,6 +53,10 @@ import type {
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiConversation,
|
||||
AiFormSchema,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionType,
|
||||
AiSkill,
|
||||
AiSseChunk,
|
||||
} from './types';
|
||||
@@ -44,6 +65,7 @@ import './style.css';
|
||||
interface AiChatDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRequestingChange?: (working: boolean) => void;
|
||||
}
|
||||
|
||||
interface ConversationData extends AiConversation {
|
||||
@@ -51,6 +73,18 @@ interface ConversationData extends AiConversation {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
|
||||
|
||||
export function conversationStatusMeta(status: ConversationRunStatus): {
|
||||
label: string;
|
||||
color: string;
|
||||
} {
|
||||
if (status === 'running') return { label: '生成中', color: 'processing' };
|
||||
if (status === 'done') return { label: '已完成', color: 'success' };
|
||||
if (status === 'error') return { label: '失败', color: 'error' };
|
||||
return { label: '已停止', color: 'default' };
|
||||
}
|
||||
|
||||
function sortConversations(items: AiConversation[]): AiConversation[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
|
||||
@@ -91,7 +125,7 @@ export const aiBubbleRoles: BubbleListProps['role'] = {
|
||||
assistant: { placement: 'start', variant: 'borderless' },
|
||||
};
|
||||
|
||||
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
@@ -99,9 +133,20 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [skills, setSkills] = useState<AiSkill[]>([]);
|
||||
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
|
||||
const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking);
|
||||
const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking);
|
||||
const [conversationStatus, setConversationStatus] = useState<
|
||||
Record<number, ConversationRunStatus>
|
||||
>({});
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const requestingRef = useRef(false);
|
||||
const abortRef = useRef<() => void>(() => undefined);
|
||||
const attachmentsRef = useRef<AiAttachment[]>([]);
|
||||
const requestAbortRef = useRef(new Map<number, () => void>());
|
||||
const providersRef = useRef(new Map<number, GongxueAiChatProvider>());
|
||||
const loadedRef = useRef(false);
|
||||
const pendingDraftConversationIdRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
@@ -112,6 +157,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
setConversation,
|
||||
setConversations,
|
||||
} = useXConversations({});
|
||||
const activeConversationKeyRef = useRef(activeConversationKey);
|
||||
|
||||
const activeConversation = useMemo(
|
||||
() => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined,
|
||||
@@ -119,29 +165,50 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
);
|
||||
const activeId = activeConversation?.id ?? null;
|
||||
const lockedSkill = skills.find((skill) => skill.key === activeConversation?.lockedSkillKey);
|
||||
activeConversationKeyRef.current = activeConversationKey;
|
||||
|
||||
useEffect(() => setSidebarOpen(!isMobile), [isMobile]);
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
|
||||
setConversations(items);
|
||||
const current = activeConversationKey;
|
||||
const current = activeConversationKeyRef.current;
|
||||
setActiveConversationKey(
|
||||
current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),
|
||||
);
|
||||
}, [activeConversationKey, setActiveConversationKey, setConversations]);
|
||||
}, [setActiveConversationKey, setConversations]);
|
||||
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
activeId
|
||||
? new GongxueAiChatProvider(conversationStreamUrl(activeId), () => {
|
||||
void refreshConversations();
|
||||
})
|
||||
: undefined,
|
||||
[activeId, refreshConversations],
|
||||
const markConversationRunning = useCallback((conversationId: number) => {
|
||||
setConversationStatus((current) => ({ ...current, [conversationId]: 'running' }));
|
||||
}, []);
|
||||
|
||||
const markConversationFinished = useCallback(
|
||||
(conversationId: number, result?: { ok: boolean; aborted?: boolean }) => {
|
||||
requestAbortRef.current.delete(conversationId);
|
||||
setConversationStatus((current) => ({
|
||||
...current,
|
||||
[conversationId]: result?.ok ? 'done' : result?.aborted ? 'stopped' : 'error',
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, onRequest, onReload, isRequesting, abort, setMessage } = useXChat<
|
||||
const provider = useMemo(
|
||||
() => {
|
||||
if (!activeId) return undefined;
|
||||
const existing = providersRef.current.get(activeId);
|
||||
if (existing) return existing;
|
||||
const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => {
|
||||
void refreshConversations();
|
||||
markConversationFinished(activeId, result);
|
||||
});
|
||||
providersRef.current.set(activeId, created);
|
||||
return created;
|
||||
},
|
||||
[activeId, markConversationFinished, refreshConversations],
|
||||
);
|
||||
|
||||
const { messages, onRequest, onReload, isRequesting, abort, setMessage, queueRequest } = useXChat<
|
||||
AiChatMessage,
|
||||
AiChatMessage,
|
||||
AiChatInput,
|
||||
@@ -164,14 +231,68 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
cancelled: error.name === 'AbortError',
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider) return;
|
||||
provider.onExternalReview = (messageId, review) => {
|
||||
setMessage(messageId, (info) => ({
|
||||
message: {
|
||||
...info.message,
|
||||
reviews: (info.message.reviews ?? []).some((item) => item.id === review.id)
|
||||
? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item))
|
||||
: [...(info.message.reviews ?? []), review],
|
||||
},
|
||||
}));
|
||||
};
|
||||
}, [provider, setMessage]);
|
||||
|
||||
requestingRef.current = isRequesting;
|
||||
abortRef.current = abort;
|
||||
attachmentsRef.current = attachments;
|
||||
|
||||
useEffect(() => {
|
||||
onRequestingChange?.(isRequesting);
|
||||
}, [isRequesting, onRequestingChange]);
|
||||
|
||||
const stopRequest = useCallback(() => {
|
||||
if (requestingRef.current) abortRef.current();
|
||||
}, []);
|
||||
|
||||
const requestWithStatus = useCallback(
|
||||
(params: AiChatInput) => {
|
||||
if (!activeId || !provider) return;
|
||||
requestAbortRef.current.set(activeId, () => provider.request.abort());
|
||||
markConversationRunning(activeId);
|
||||
onRequest(params);
|
||||
},
|
||||
[activeId, markConversationRunning, onRequest, provider],
|
||||
);
|
||||
|
||||
const reloadWithStatus = useCallback(
|
||||
(messageInfo: MessageInfo<AiChatMessage>) => {
|
||||
if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return;
|
||||
requestAbortRef.current.set(activeId, () => provider.request.abort());
|
||||
markConversationRunning(activeId);
|
||||
onReload(messageInfo.id, {
|
||||
message: '',
|
||||
attachmentIds: [],
|
||||
skillKey: activeConversation?.lockedSkillKey ?? null,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
reasoningEffort: deepThinking ? 'high' : null,
|
||||
regenerateMessageId: messageInfo.message.id,
|
||||
reloadMessage: messageInfo.message,
|
||||
});
|
||||
},
|
||||
[
|
||||
activeConversation?.lockedSkillKey,
|
||||
activeId,
|
||||
deepThinking,
|
||||
markConversationRunning,
|
||||
onReload,
|
||||
provider,
|
||||
],
|
||||
);
|
||||
|
||||
const discardPendingAttachments = useCallback(() => {
|
||||
const pending = attachmentsRef.current;
|
||||
attachmentsRef.current = [];
|
||||
@@ -182,16 +303,15 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || loadedRef.current) return;
|
||||
let cancelled = false;
|
||||
setLoadingList(true);
|
||||
Promise.all([aiChatApi.listSkills(), aiChatApi.listConversations()])
|
||||
.then(async ([skillItems, conversationItems]) => {
|
||||
.then(([skillItems, conversationItems]) => {
|
||||
if (cancelled) return;
|
||||
loadedRef.current = true;
|
||||
setSkills(skillItems);
|
||||
let next = sortConversations(conversationItems);
|
||||
if (!next.length) next = [await aiChatApi.createConversation()];
|
||||
const data = next.map(toConversationData);
|
||||
const data = sortConversations(conversationItems).map(toConversationData);
|
||||
setConversations(data);
|
||||
setActiveConversationKey(data[0]?.key ?? '');
|
||||
})
|
||||
@@ -207,19 +327,20 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}, [activeConversationKey, discardPendingAttachments, isMobile]);
|
||||
|
||||
useEffect(() => () => stopRequest(), [stopRequest]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const abort of requestAbortRef.current.values()) abort();
|
||||
requestAbortRef.current.clear();
|
||||
providersRef.current.clear();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const createConversation = useCallback(async () => {
|
||||
try {
|
||||
stopRequest();
|
||||
const created = toConversationData(await aiChatApi.createConversation());
|
||||
addConversation(created, 'prepend');
|
||||
setActiveConversationKey(created.key);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
} catch {
|
||||
message.error('新建会话失败');
|
||||
}
|
||||
}, [addConversation, isMobile, setActiveConversationKey, stopRequest]);
|
||||
/** 新建对话(Codex 风格):先进入草稿态,发送第一条消息时才创建 session */
|
||||
const startNewConversation = useCallback(() => {
|
||||
setActiveConversationKey('');
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}, [isMobile, setActiveConversationKey]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
(conversation: ConversationData) => {
|
||||
@@ -243,6 +364,23 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
[setConversation],
|
||||
);
|
||||
|
||||
/** 删除单个会话时中止请求并清理会话运行时状态 */
|
||||
const removeConversationEntry = useCallback(
|
||||
(conversation: ConversationData) => {
|
||||
const abortRequest = requestAbortRef.current.get(conversation.id);
|
||||
if (abortRequest) abortRequest();
|
||||
else if (conversation.id === activeId) stopRequest();
|
||||
requestAbortRef.current.delete(conversation.id);
|
||||
providersRef.current.delete(conversation.id);
|
||||
setConversationStatus((current) => {
|
||||
const next = { ...current };
|
||||
delete next[conversation.id];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeId, setConversationStatus, stopRequest],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
(conversation: ConversationData) => {
|
||||
Modal.confirm({
|
||||
@@ -252,23 +390,113 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (conversation.id === activeId) stopRequest();
|
||||
await aiChatApi.deleteConversation(conversation.id);
|
||||
removeConversationEntry(conversation);
|
||||
removeConversation(conversation.key);
|
||||
const remaining = conversations.filter((item) => item.key !== conversation.key);
|
||||
if (!remaining.length) {
|
||||
const created = toConversationData(await aiChatApi.createConversation());
|
||||
addConversation(created, 'prepend');
|
||||
setActiveConversationKey(created.key);
|
||||
setActiveConversationKey('');
|
||||
} else if (conversation.id === activeId) {
|
||||
setActiveConversationKey(remaining[0].key);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[activeId, addConversation, conversations, removeConversation, setActiveConversationKey, stopRequest],
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
setActiveConversationKey,
|
||||
],
|
||||
);
|
||||
|
||||
const enterSelectionMode = useCallback(() => {
|
||||
setSelectedKeys([]);
|
||||
setSelectionMode(true);
|
||||
}, []);
|
||||
|
||||
const exitSelectionMode = useCallback(() => {
|
||||
setSelectedKeys([]);
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
const selectAllConversations = useCallback(() => {
|
||||
setSelectedKeys(conversations.map((item) => item.key));
|
||||
}, [conversations]);
|
||||
|
||||
const invertConversationSelection = useCallback(() => {
|
||||
setSelectedKeys((current) => {
|
||||
const selected = new Set(current);
|
||||
return conversations.map((item) => item.key).filter((key) => !selected.has(key));
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
const toggleConversationSelection = useCallback((key: string) => {
|
||||
setSelectedKeys((current) =>
|
||||
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteSelectedConversations = useCallback(() => {
|
||||
const selected = conversations.filter((item) =>
|
||||
selectedKeys.includes(item.key),
|
||||
) as ConversationData[];
|
||||
if (!selected.length) return;
|
||||
Modal.confirm({
|
||||
title: `删除选中的 ${selected.length} 个会话`,
|
||||
content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (selected.length === conversations.length) {
|
||||
for (const abort of requestAbortRef.current.values()) abort();
|
||||
requestAbortRef.current.clear();
|
||||
providersRef.current.clear();
|
||||
setConversationStatus({});
|
||||
await aiChatApi.deleteAllConversations();
|
||||
setConversations([]);
|
||||
setActiveConversationKey('');
|
||||
} else {
|
||||
for (const item of selected) removeConversationEntry(item);
|
||||
const deletedKeys: string[] = [];
|
||||
const failedTitles: string[] = [];
|
||||
await Promise.all(
|
||||
selected.map(async (item) => {
|
||||
try {
|
||||
await aiChatApi.deleteConversation(item.id);
|
||||
removeConversation(item.key);
|
||||
deletedKeys.push(item.key);
|
||||
} catch {
|
||||
failedTitles.push(item.title);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const deleted = new Set(deletedKeys);
|
||||
const remaining = conversations.filter((item) => !deleted.has(item.key));
|
||||
setConversations(remaining);
|
||||
if (!remaining.length) {
|
||||
setActiveConversationKey('');
|
||||
} else if (activeId != null && !remaining.some((item) => item.id === activeId)) {
|
||||
setActiveConversationKey(remaining[0].key);
|
||||
}
|
||||
if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`);
|
||||
}
|
||||
setSelectedKeys([]);
|
||||
setSelectionMode(false);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeId,
|
||||
conversations,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
setActiveConversationKey,
|
||||
setConversations,
|
||||
]);
|
||||
|
||||
const conversationMenu = useCallback(
|
||||
(item: ConversationItemType): MenuProps => ({
|
||||
items: [
|
||||
@@ -303,33 +531,157 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
const submit = useCallback(
|
||||
(value: string) => {
|
||||
const text = value.trim();
|
||||
if (!text || !activeId || isRequesting) return;
|
||||
if (!text || isRequesting) return;
|
||||
const submittedAttachments = attachmentsRef.current;
|
||||
attachmentsRef.current = [];
|
||||
onRequest({
|
||||
setAttachments([]);
|
||||
setInput('');
|
||||
const params: AiChatInput = {
|
||||
message: text,
|
||||
attachmentIds: submittedAttachments.map((item) => item.id),
|
||||
skillKey: activeConversation?.lockedSkillKey ?? null,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
reasoningEffort: deepThinking ? 'high' : null,
|
||||
localAttachments: submittedAttachments,
|
||||
});
|
||||
setInput('');
|
||||
setAttachments([]);
|
||||
}, [activeConversation?.lockedSkillKey, activeId, isRequesting, onRequest]);
|
||||
};
|
||||
if (activeId != null) {
|
||||
requestWithStatus(params);
|
||||
return;
|
||||
}
|
||||
// 草稿态:先创建 session,再发送第一条消息
|
||||
void (async () => {
|
||||
try {
|
||||
const created = toConversationData(await aiChatApi.createConversation());
|
||||
addConversation(created, 'prepend');
|
||||
pendingDraftConversationIdRef.current = created.id;
|
||||
markConversationRunning(created.id);
|
||||
// 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出,
|
||||
// 保证消息写入新会话的 store,界面能正常显示对话内容。
|
||||
queueRequest(created.key, params);
|
||||
setActiveConversationKey(created.key);
|
||||
} catch {
|
||||
message.error('创建会话失败,请重试');
|
||||
attachmentsRef.current = submittedAttachments;
|
||||
setAttachments(submittedAttachments);
|
||||
setInput(text);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
activeConversation?.lockedSkillKey,
|
||||
activeId,
|
||||
addConversation,
|
||||
deepThinking,
|
||||
isRequesting,
|
||||
markConversationRunning,
|
||||
queueRequest,
|
||||
requestWithStatus,
|
||||
setActiveConversationKey,
|
||||
],
|
||||
);
|
||||
|
||||
// 草稿 session 创建完成、provider 就绪后注册中止句柄
|
||||
useEffect(() => {
|
||||
if (activeId == null || !provider) return;
|
||||
if (activeId !== pendingDraftConversationIdRef.current) return;
|
||||
pendingDraftConversationIdRef.current = null;
|
||||
requestAbortRef.current.set(activeId, () => provider.request.abort());
|
||||
}, [activeId, provider]);
|
||||
|
||||
const reloadMessage = useCallback(
|
||||
(messageInfo: MessageInfo<AiChatMessage>) => {
|
||||
if (!activeId || typeof messageInfo.message.id !== 'number') return;
|
||||
onReload(messageInfo.id, {
|
||||
message: '',
|
||||
reloadWithStatus(messageInfo);
|
||||
},
|
||||
[reloadWithStatus],
|
||||
);
|
||||
|
||||
const submitForm = useCallback(
|
||||
(form: AiFormSchema, values: Record<string, unknown>) => {
|
||||
if (!activeId || isRequesting) return;
|
||||
requestWithStatus({
|
||||
message: '表单提交',
|
||||
attachmentIds: [],
|
||||
skillKey: activeConversation?.lockedSkillKey ?? null,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
regenerateMessageId: messageInfo.message.id,
|
||||
reloadMessage: messageInfo.message,
|
||||
reasoningEffort: deepThinking ? 'high' : null,
|
||||
formSubmission: { formId: form.id, values, formTitle: form.title },
|
||||
});
|
||||
},
|
||||
[activeConversation?.lockedSkillKey, activeId, onReload],
|
||||
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
|
||||
);
|
||||
|
||||
const submitReview = useCallback(
|
||||
(reviewId: string, reviewTitle?: string) => {
|
||||
if (!activeId || isRequesting) return;
|
||||
requestWithStatus({
|
||||
message: '确认批量导入',
|
||||
attachmentIds: [],
|
||||
skillKey: activeConversation?.lockedSkillKey ?? null,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
reasoningEffort: deepThinking ? 'high' : null,
|
||||
reviewSubmission: { reviewId, reviewTitle },
|
||||
});
|
||||
},
|
||||
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
|
||||
);
|
||||
|
||||
const confirmReviewStep = useCallback(
|
||||
async (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
sectionKey: AiReviewSection['key'],
|
||||
): Promise<AiReviewSchema> => {
|
||||
const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey);
|
||||
const apply = (review: AiReviewSchema) => {
|
||||
if (provider?.onExternalReview && typeof messageId === 'number') {
|
||||
provider.onExternalReview(messageId, review);
|
||||
} else if (typeof messageId === 'number') {
|
||||
setMessage(messageId, (info) => {
|
||||
const reviews = info.message.reviews ?? [];
|
||||
const exists = reviews.some((item) => item.id === review.id);
|
||||
return {
|
||||
message: {
|
||||
...info.message,
|
||||
reviews: exists
|
||||
? reviews.map((item) => (item.id === review.id ? review : item))
|
||||
: [...reviews, review],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
apply(updated);
|
||||
return updated;
|
||||
},
|
||||
[provider, setMessage],
|
||||
);
|
||||
|
||||
const confirmReviewGroup = useCallback(
|
||||
async (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
type: AiReviewSectionType,
|
||||
): Promise<AiReviewSchema> => {
|
||||
const updated = await aiChatApi.confirmReviewGroup(reviewId, type);
|
||||
if (provider?.onExternalReview && typeof messageId === 'number') {
|
||||
provider.onExternalReview(messageId, updated);
|
||||
} else if (typeof messageId === 'number') {
|
||||
setMessage(messageId, (info) => {
|
||||
const reviews = info.message.reviews ?? [];
|
||||
const exists = reviews.some((item) => item.id === updated.id);
|
||||
return {
|
||||
message: {
|
||||
...info.message,
|
||||
reviews: exists
|
||||
? reviews.map((item) => (item.id === updated.id ? updated : item))
|
||||
: [...reviews, updated],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[provider, setMessage],
|
||||
);
|
||||
|
||||
const updateFeedback = useCallback(
|
||||
@@ -405,10 +757,57 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
status={info.status as AiChatMessageStatus}
|
||||
onReload={content.role === 'assistant' && info.status !== 'loading' ? () => reloadMessage(info) : undefined}
|
||||
onFeedback={content.role === 'assistant' ? (feedback) => void updateFeedback(info, feedback) : undefined}
|
||||
onSubmitForm={submitForm}
|
||||
onSubmitReview={submitReview}
|
||||
onConfirmReviewStep={confirmReviewStep}
|
||||
onConfirmReviewGroup={confirmReviewGroup}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[messages, reloadMessage, updateFeedback],
|
||||
[confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback],
|
||||
);
|
||||
|
||||
const conversationItems = useMemo<ConversationItemType[]>(
|
||||
() =>
|
||||
conversations.map((item) => {
|
||||
const status = conversationStatus[item.id];
|
||||
let statusIndicator: React.ReactNode = null;
|
||||
if (status === 'running') {
|
||||
statusIndicator = (
|
||||
<LoadingOutlined
|
||||
spin
|
||||
className="ai-chat-conversation-loading"
|
||||
aria-label="生成中"
|
||||
role="status"
|
||||
/>
|
||||
);
|
||||
} else if (status === 'error' || status === 'stopped') {
|
||||
statusIndicator = (
|
||||
<span
|
||||
className={`ai-chat-conversation-state is-${status}`}
|
||||
aria-label={conversationStatusMeta(status).label}
|
||||
>
|
||||
<i />
|
||||
{conversationStatusMeta(status).label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = (
|
||||
<span className="ai-chat-conversation-label">
|
||||
{selectionMode && (
|
||||
<Checkbox
|
||||
checked={selectedKeys.includes(item.key)}
|
||||
className="ai-chat-conversation-check"
|
||||
aria-label={`选择 ${item.title}`}
|
||||
/>
|
||||
)}
|
||||
<span className="ai-chat-conversation-label__title">{item.title}</span>
|
||||
{statusIndicator}
|
||||
</span>
|
||||
);
|
||||
return { ...item, label };
|
||||
}),
|
||||
[conversationStatus, conversations, selectedKeys, selectionMode],
|
||||
);
|
||||
|
||||
const skillMenu: MenuProps = {
|
||||
@@ -423,13 +822,10 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<span className="ai-chat-title"><RobotOutlined />功学 AI 助手</span>}
|
||||
title={<span className="ai-chat-title"><RobotOutlined />恭学 AI 助手</span>}
|
||||
open={open}
|
||||
onClose={() => {
|
||||
stopRequest();
|
||||
discardPendingAttachments();
|
||||
onClose();
|
||||
}}
|
||||
closeIcon={<ArrowRightOutlined title="收起到后台继续运行" />}
|
||||
onClose={onClose}
|
||||
width={isMobile ? '100%' : 'min(1040px, 92vw)'}
|
||||
destroyOnHidden={false}
|
||||
className="ai-chat-drawer"
|
||||
@@ -438,16 +834,55 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
<div className="ai-chat-layout">
|
||||
<aside className={`ai-chat-sidebar${sidebarOpen ? ' is-open' : ''}`}>
|
||||
<Conversations
|
||||
items={conversations as ConversationItemType[]}
|
||||
items={conversationItems}
|
||||
activeKey={activeConversationKey}
|
||||
onActiveChange={(key) => {
|
||||
stopRequest();
|
||||
setActiveConversationKey(key);
|
||||
if (selectionMode) toggleConversationSelection(key);
|
||||
else setActiveConversationKey(key);
|
||||
}}
|
||||
menu={conversationMenu}
|
||||
creation={{ label: '新对话', icon: <PlusOutlined />, onClick: createConversation }}
|
||||
menu={selectionMode ? undefined : conversationMenu}
|
||||
creation={
|
||||
selectionMode
|
||||
? undefined
|
||||
: { label: '新对话', icon: <PlusOutlined />, onClick: startNewConversation }
|
||||
}
|
||||
/>
|
||||
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
||||
<div className="ai-chat-sidebar__footer">
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} 已选</span>
|
||||
<Button size="small" type="text" onClick={selectAllConversations}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={invertConversationSelection}>
|
||||
反选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
disabled={selectedKeys.length === 0}
|
||||
onClick={deleteSelectedConversations}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={exitSelectionMode}>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckSquareOutlined />}
|
||||
disabled={conversations.length === 0}
|
||||
onClick={enterSelectionMode}
|
||||
>
|
||||
管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="ai-chat-main">
|
||||
@@ -473,8 +908,8 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
<Welcome
|
||||
variant="borderless"
|
||||
icon={<RobotOutlined />}
|
||||
title="你好,我是功学 AI 助手"
|
||||
description={lockedSkill?.description || '我会在你的权限范围内查询学生、考勤、宿舍、账单和经营数据。'}
|
||||
title="你好,我是恭学 AI 助手"
|
||||
description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'}
|
||||
/>
|
||||
<Prompts
|
||||
title="你可以这样问"
|
||||
@@ -505,31 +940,44 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose }) => {
|
||||
: undefined
|
||||
}
|
||||
header={
|
||||
uploadItems.length ? (
|
||||
<Attachments
|
||||
items={uploadItems}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
/>
|
||||
) : false
|
||||
uploadItems.length > 0 && (
|
||||
<div className="ai-chat-sender-header">
|
||||
<Attachments
|
||||
items={uploadItems}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
prefix={
|
||||
<Attachments
|
||||
items={[]}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
placeholder={{ title: '添加附件', description: '图片、PDF、Word、Excel,单个不超过 10MB' }}
|
||||
>
|
||||
<Button type="text" size="small">附件</Button>
|
||||
</Attachments>
|
||||
footer={
|
||||
<div className="ai-chat-sender-footer">
|
||||
<Tooltip title="添加附件">
|
||||
<Attachments
|
||||
items={[]}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
placeholder={{ title: '添加附件', description: '图片、PDF、Word、Excel,单个不超过 10MB' }}
|
||||
>
|
||||
<Button type="text" icon={<PaperClipOutlined />} aria-label="添加附件" />
|
||||
</Attachments>
|
||||
</Tooltip>
|
||||
<SenderSwitch
|
||||
checkedChildren="深度思考"
|
||||
unCheckedChildren="普通"
|
||||
value={deepThinking}
|
||||
onChange={setDeepThinking}
|
||||
disabled={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Typography.Text type="secondary" className="ai-chat-disclaimer">
|
||||
AI 仅查询你有权限查看的数据,重要信息请以系统记录为准
|
||||
AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -10,16 +10,33 @@ import {
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Actions, CodeHighlighter, FileCard, Think, ThoughtChain } from '@ant-design/x';
|
||||
import {
|
||||
Actions,
|
||||
CodeHighlighter,
|
||||
FileCard,
|
||||
Mermaid,
|
||||
Sources,
|
||||
Think,
|
||||
ThoughtChain,
|
||||
} from '@ant-design/x';
|
||||
import type { ThoughtChainItemType } from '@ant-design/x';
|
||||
import XMarkdown from '@ant-design/x-markdown';
|
||||
import type { ComponentProps } from '@ant-design/x-markdown';
|
||||
import { Alert, Flex, Space, Typography } from 'antd';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import type {
|
||||
AiAttachment,
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiMessageFeedback,
|
||||
AiReviewSection,
|
||||
AiReviewSchema,
|
||||
AiReviewSectionType,
|
||||
AiToolRun,
|
||||
} from './types';
|
||||
|
||||
@@ -32,12 +49,24 @@ const toolLabels: Record<string, string> = {
|
||||
get_room_occupancy_summary: '统计入住',
|
||||
search_bills: '查询账单',
|
||||
get_dashboard_stats: '读取经营概览',
|
||||
render_form: '生成表单',
|
||||
render_review: '生成导入预览',
|
||||
render_chart: '生成图表',
|
||||
create_student: '创建学生',
|
||||
search_exams: '查询考试',
|
||||
search_schedules: '查询课表',
|
||||
search_deposits: '查询押金',
|
||||
search_expenses: '查询费用',
|
||||
search_classrooms: '查询教室',
|
||||
search_classroom_rentals: '查询教室租用',
|
||||
get_sync_status: '查询同步状态',
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: ({ children, lang, block }: ComponentProps) => {
|
||||
const content = String(children ?? '').replace(/\n$/, '');
|
||||
if (!block) return <code>{content}</code>;
|
||||
if (lang === 'mermaid') return <Mermaid>{content}</Mermaid>;
|
||||
return <CodeHighlighter lang={lang || 'text'}>{content}</CodeHighlighter>;
|
||||
},
|
||||
};
|
||||
@@ -57,7 +86,7 @@ function attachmentIcon(attachment: AiAttachment) {
|
||||
}
|
||||
|
||||
async function openAttachment(attachment: AiAttachment): Promise<void> {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
const response = await fetch(attachment.url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
@@ -67,6 +96,18 @@ async function openAttachment(attachment: AiAttachment): Promise<void> {
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
|
||||
async function openSourceUrl(item: { url?: string }): Promise<void> {
|
||||
if (!item.url) return;
|
||||
const token = useUserStore.getState().token;
|
||||
const response = await fetch(item.url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) throw new Error('来源打开失败');
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
window.open(objectUrl, '_blank', 'noopener,noreferrer');
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
|
||||
function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
||||
const items = useMemo<ThoughtChainItemType[]>(
|
||||
() =>
|
||||
@@ -99,6 +140,18 @@ export interface AiMessageContentProps {
|
||||
status?: AiChatMessageStatus;
|
||||
onReload?: () => void;
|
||||
onFeedback?: (feedback: AiMessageFeedback) => void;
|
||||
onSubmitForm?: (form: AiFormSchema, values: Record<string, unknown>) => void;
|
||||
onSubmitReview?: (reviewId: string, reviewTitle?: string) => void;
|
||||
onConfirmReviewStep?: (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
sectionKey: AiReviewSection['key'],
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
onConfirmReviewGroup?: (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
type: AiReviewSectionType,
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
}
|
||||
|
||||
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
@@ -106,8 +159,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
status,
|
||||
onReload,
|
||||
onFeedback,
|
||||
onSubmitForm,
|
||||
onSubmitReview,
|
||||
onConfirmReviewStep,
|
||||
onConfirmReviewGroup,
|
||||
}) => {
|
||||
const streaming = status === 'loading' || status === 'updating';
|
||||
const formSubmission = message.metadata?.a2uiSubmit;
|
||||
const reviewSubmission = message.metadata?.a2uiReviewSubmit;
|
||||
const sourceMeta = message.metadata?.a2uiSources;
|
||||
const sourceItems = Array.isArray(sourceMeta)
|
||||
? sourceMeta
|
||||
.filter(
|
||||
(item): item is { title: string; url?: string; description?: string } =>
|
||||
Boolean(item) && typeof (item as { title?: unknown }).title === 'string',
|
||||
)
|
||||
.map((item, index) => ({
|
||||
key: `source-${index}`,
|
||||
title: item.title,
|
||||
...(item.url ? { url: item.url } : {}),
|
||||
...(item.description ? { description: item.description } : {}),
|
||||
}))
|
||||
: [];
|
||||
const attachmentCards = message.attachments.map((attachment) => (
|
||||
<FileCard
|
||||
key={attachment.id}
|
||||
@@ -120,6 +193,28 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
));
|
||||
|
||||
if (message.role === 'user') {
|
||||
if (reviewSubmission && typeof reviewSubmission === 'object') {
|
||||
const reviewTitle =
|
||||
typeof (reviewSubmission as Record<string, unknown>).reviewTitle === 'string'
|
||||
? String((reviewSubmission as Record<string, unknown>).reviewTitle)
|
||||
: '批量导入';
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="success" showIcon message={`已确认导入《${reviewTitle}》`} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (formSubmission && typeof formSubmission === 'object') {
|
||||
const formTitle =
|
||||
typeof (formSubmission as Record<string, unknown>).formTitle === 'string'
|
||||
? String((formSubmission as Record<string, unknown>).formTitle)
|
||||
: '表单';
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="info" showIcon message={`已提交《${formTitle}》`} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
|
||||
@@ -158,6 +253,19 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={10} className="ai-chat-answer">
|
||||
{streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && (
|
||||
<div className="ai-chat-streaming-placeholder" role="status" aria-label="生成中">
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
{message.retrying && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
|
||||
description={message.retrying.reason ? `原因:${message.retrying.reason}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{message.reasoningContent && (
|
||||
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
|
||||
<XMarkdown
|
||||
@@ -187,6 +295,35 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceItems.length > 0 && (
|
||||
<Sources
|
||||
items={sourceItems}
|
||||
title="引用来源"
|
||||
onClick={(item) => void openSourceUrl(item as { url?: string })}
|
||||
/>
|
||||
)}
|
||||
{(message.forms ?? []).map((form) => (
|
||||
<DynamicForm
|
||||
key={form.id}
|
||||
form={form}
|
||||
disabled={streaming}
|
||||
onSubmit={(values) => onSubmitForm?.(form, values)}
|
||||
/>
|
||||
))}
|
||||
{(message.reviews ?? []).map((review: AiReviewSchema) => (
|
||||
<DynamicReview
|
||||
key={review.id}
|
||||
review={review}
|
||||
messageId={typeof message.id === 'number' ? message.id : undefined}
|
||||
disabled={streaming}
|
||||
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
|
||||
onConfirmStep={onConfirmReviewStep}
|
||||
onConfirmGroup={onConfirmReviewGroup}
|
||||
/>
|
||||
))}
|
||||
{(message.charts ?? []).map((chart: AiChartSchema) => (
|
||||
<DynamicChart key={chart.id} chart={chart} />
|
||||
))}
|
||||
{message.error && <Alert type="error" showIcon message={message.error} />}
|
||||
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
||||
{!streaming && message.content && <Actions items={actionItems} fadeIn />}
|
||||
|
||||
295
apps/admin/src/components/AiChat/DynamicChart.tsx
Normal file
295
apps/admin/src/components/AiChat/DynamicChart.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Button, Tag, Tooltip, Typography } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
|
||||
import type { AiChartSchema } from './types';
|
||||
|
||||
const CHART_CATALOG_ID = 'gongxue-chart-catalog';
|
||||
|
||||
registerCatalog({
|
||||
catalogId: CHART_CATALOG_ID,
|
||||
components: {
|
||||
ChartPreview: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
chart: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function surfaceId(chartId: string): string {
|
||||
return `chart-${chartId}`;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
const CHART_TYPE_LABELS: Record<string, string> = {
|
||||
line: '折线图',
|
||||
bar: '柱状图',
|
||||
pie: '饼图',
|
||||
area: '面积图',
|
||||
scatter: '散点图',
|
||||
radar: '雷达图',
|
||||
gauge: '仪表盘',
|
||||
funnel: '漏斗图',
|
||||
};
|
||||
|
||||
function buildOption(chart: AiChartSchema): EChartsOption {
|
||||
const columns = chart.columns;
|
||||
if (chart.chartType === 'scatter') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const xField = columns[1]?.key ?? '';
|
||||
const yField = columns[2]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: [numberValue(row[xField]), numberValue(row[yField])],
|
||||
}));
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: unknown) => {
|
||||
const item = params as { name?: string; value?: number[] };
|
||||
const [x, y] = item.value ?? [];
|
||||
return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
|
||||
xAxis: { type: 'value', name: columns[1]?.title },
|
||||
yAxis: { type: 'value', name: columns[2]?.title },
|
||||
series: [{ type: 'scatter', symbolSize: 10, data }],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'radar') {
|
||||
const seriesNameField = columns[0]?.key ?? '';
|
||||
const indicatorColumns = columns.slice(1);
|
||||
const indicators = indicatorColumns.map((column) => {
|
||||
const values = chart.rows.map((row) => numberValue(row[column.key]));
|
||||
const max = Math.max(1, ...values);
|
||||
return { name: column.title, max: Math.ceil(max * 1.1) };
|
||||
});
|
||||
const seriesData = chart.rows.map((row) => ({
|
||||
name: String(row[seriesNameField] ?? ''),
|
||||
value: indicatorColumns.map((column) => numberValue(row[column.key])),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
radar: { indicator: indicators, radius: '65%' },
|
||||
series: [{ type: 'radar', data: seriesData }],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'gauge') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const maxField = columns[2]?.key;
|
||||
const gauges = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
max: maxField ? Math.max(1, numberValue(row[maxField])) : 100,
|
||||
}));
|
||||
return {
|
||||
series: gauges.map((gauge, index) => ({
|
||||
type: 'gauge',
|
||||
center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'],
|
||||
radius: '75%',
|
||||
min: 0,
|
||||
max: gauge.max,
|
||||
title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 },
|
||||
detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] },
|
||||
data: [{ value: gauge.value, name: gauge.name }],
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'funnel') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'funnel',
|
||||
left: '10%',
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
width: '80%',
|
||||
minSize: '20%',
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'pie') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '68%'],
|
||||
center: ['50%', '45%'],
|
||||
data,
|
||||
label: { formatter: '{b}: {c}' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const categoryField = columns[0]?.key ?? '';
|
||||
const categories = chart.rows.map((row) => String(row[categoryField] ?? ''));
|
||||
const series = columns.slice(1).map((column) => ({
|
||||
name: column.title,
|
||||
type: chart.chartType === 'area' ? 'line' : chart.chartType,
|
||||
smooth: chart.chartType === 'line',
|
||||
...(chart.chartType === 'area' ? { areaStyle: { opacity: 0.18 } } : {}),
|
||||
data: chart.rows.map((row) => numberValue(row[column.key])),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLabel: { interval: 0, rotate: categories.length > 8 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series,
|
||||
};
|
||||
}
|
||||
|
||||
interface ChartPreviewProps {
|
||||
chart?: AiChartSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI component registered for the `gongxue-chart-catalog` catalog.
|
||||
* Receives the validated tabular chart data through data binding and
|
||||
* renders an ECharts option built from it.
|
||||
*/
|
||||
const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
||||
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
|
||||
const [instance, setInstance] = useState<EChartsType | null>(null);
|
||||
if (!chart) return null;
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!instance) return;
|
||||
const url = instance.getDataURL({
|
||||
type: 'png',
|
||||
pixelRatio: 2,
|
||||
backgroundColor: '#fff',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${chart.title || '图表'}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-chart-card">
|
||||
<div className="ai-chat-chart-card__header">
|
||||
<Typography.Text strong>{chart.title}</Typography.Text>
|
||||
<span className="ai-chat-chart-card__header-actions">
|
||||
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
|
||||
<Tooltip title="下载图片">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="ai-chat-chart-card__download"
|
||||
aria-label="下载图表图片"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadImage}
|
||||
disabled={!instance}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ width: '100%', height: 260 }}
|
||||
onReady={setInstance}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DynamicChartProps {
|
||||
chart: AiChartSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart card rendered through the official @ant-design/x-card renderer.
|
||||
* Display-only: no submit endpoint, the schema lives in message metadata
|
||||
* so history replays identically.
|
||||
*/
|
||||
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(chart.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/chart',
|
||||
value: chart,
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ChartPreview',
|
||||
chart: { path: '/chart' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<div className="ai-chat-chart">
|
||||
<XCard.Box components={{ ChartPreview }} commands={commands}>
|
||||
<XCard.Card id={surfaceId(chart.id)} />
|
||||
</XCard.Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicChart;
|
||||
239
apps/admin/src/components/AiChat/DynamicForm.tsx
Normal file
239
apps/admin/src/components/AiChat/DynamicForm.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { AiFormField, AiFormSchema } from './types';
|
||||
|
||||
const FORM_CATALOG_ID = 'gongxue-form-catalog';
|
||||
|
||||
registerCatalog({
|
||||
catalogId: FORM_CATALOG_ID,
|
||||
components: {
|
||||
FormPreview: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
form: { type: 'object' },
|
||||
disabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function surfaceId(formId: string): string {
|
||||
return `form-${formId}`;
|
||||
}
|
||||
|
||||
function initialValue(field: AiFormField): unknown {
|
||||
if (field.type === 'date' && typeof field.defaultValue === 'string') {
|
||||
const parsed = dayjs(field.defaultValue);
|
||||
return parsed.isValid() ? parsed : undefined;
|
||||
}
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
function normalizeValues(
|
||||
fields: AiFormField[],
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = raw[field.name];
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
values[field.name] =
|
||||
field.type === 'date' && dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
interface FormPreviewProps {
|
||||
form?: AiFormSchema;
|
||||
disabled?: boolean;
|
||||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI component registered for the `gongxue-form-catalog` catalog.
|
||||
* Receives the validated form schema through data binding and reports
|
||||
* normalized values back through the `form:submit` action.
|
||||
*/
|
||||
const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) => {
|
||||
const runtime = form as unknown as {
|
||||
submitting?: boolean;
|
||||
submitted?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
const submitting = Boolean(runtime.submitting);
|
||||
const initialValues = useMemo(
|
||||
() => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
|
||||
[form?.fields],
|
||||
);
|
||||
if (!form) return null;
|
||||
const finished = Boolean(runtime.submitted) || form.status === 'submitted';
|
||||
|
||||
const handleFinish = (values: Record<string, unknown>) => {
|
||||
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical gap={8}>
|
||||
<Typography.Text strong>{form.title}</Typography.Text>
|
||||
{form.description && (
|
||||
<Typography.Text type="secondary" className="ai-chat-dynamic-form__desc">
|
||||
{form.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{finished ? (
|
||||
<Alert type="success" showIcon message="已提交,AI 正在处理…" />
|
||||
) : (
|
||||
<Form
|
||||
layout="vertical"
|
||||
size="small"
|
||||
initialValues={initialValues}
|
||||
onFinish={(values) => void handleFinish(values as Record<string, unknown>)}
|
||||
disabled={disabled || submitting}
|
||||
requiredMark={false}
|
||||
>
|
||||
{form.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
rules={[
|
||||
{
|
||||
required: field.required,
|
||||
message: field.required
|
||||
? field.type === 'select' || field.type === 'date'
|
||||
? `请选择${field.label}`
|
||||
: `请输入${field.label}`
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{field.type === 'textarea' ? (
|
||||
<Input.TextArea rows={3} placeholder={field.placeholder} />
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
className="ai-chat-dynamic-form__number"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<Select
|
||||
allowClear={!field.required}
|
||||
placeholder={field.placeholder}
|
||||
options={field.options}
|
||||
/>
|
||||
) : field.type === 'date' ? (
|
||||
<DatePicker className="ai-chat-dynamic-form__date" placeholder={field.placeholder} />
|
||||
) : (
|
||||
<Input placeholder={field.placeholder} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
{runtime.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.error}
|
||||
className="ai-chat-dynamic-form__error"
|
||||
/>
|
||||
)}
|
||||
<Button type="primary" htmlType="submit" loading={submitting} disabled={disabled}>
|
||||
{form.submitLabel || '提交'}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DynamicFormProps {
|
||||
form: AiFormSchema;
|
||||
disabled?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI form rendered through the official @ant-design/x-card renderer.
|
||||
* The validated schema is bound into the surface data model; submit
|
||||
* success/failure/loading transitions are pushed as incremental commands.
|
||||
*/
|
||||
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(form.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/form',
|
||||
value: { ...form, submitting, submitted, error },
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'FormPreview',
|
||||
form: { path: '/form' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [disabled, error, form, submitted, submitting]);
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
setSubmitted(true);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (payload: ActionPayload) => {
|
||||
if (payload.name !== 'form:submit') return;
|
||||
const values =
|
||||
payload.context?.values && typeof payload.context.values === 'object'
|
||||
? (payload.context.values as Record<string, unknown>)
|
||||
: {};
|
||||
void handleSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-dynamic-form">
|
||||
<XCard.Box components={{ FormPreview }} commands={commands} onAction={handleAction}>
|
||||
<XCard.Card id={surfaceId(form.id)} />
|
||||
</XCard.Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicForm;
|
||||
699
apps/admin/src/components/AiChat/DynamicReview.tsx
Normal file
699
apps/admin/src/components/AiChat/DynamicReview.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Alert, Button, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import type {
|
||||
AiReviewRow,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionStatus,
|
||||
AiReviewSectionType,
|
||||
} from './types';
|
||||
|
||||
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
|
||||
|
||||
registerCatalog({
|
||||
catalogId: REVIEW_CATALOG_ID,
|
||||
components: {
|
||||
ReviewPreview: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
review: { type: 'object' },
|
||||
disabled: { type: 'boolean' },
|
||||
activeKey: { type: 'string' },
|
||||
activeType: { type: 'string' },
|
||||
submittingKey: { type: ['string', 'null'] },
|
||||
submittingGroup: { type: 'boolean' },
|
||||
error: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function surfaceId(reviewId: string): string {
|
||||
return `review-${reviewId}`;
|
||||
}
|
||||
|
||||
const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
|
||||
students: '学生',
|
||||
rooms: '宿舍',
|
||||
transfers: '换宿',
|
||||
checkins: '入住记录',
|
||||
};
|
||||
|
||||
const SECTION_ORDER: AiReviewSectionType[] = [
|
||||
'students',
|
||||
'rooms',
|
||||
'transfers',
|
||||
'checkins',
|
||||
];
|
||||
|
||||
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
|
||||
students: [],
|
||||
rooms: [],
|
||||
transfers: ['students', 'rooms'],
|
||||
checkins: [],
|
||||
};
|
||||
|
||||
function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
|
||||
if (
|
||||
section.type === 'students' ||
|
||||
section.type === 'rooms' ||
|
||||
section.type === 'transfers' ||
|
||||
section.type === 'checkins'
|
||||
) {
|
||||
return section.type;
|
||||
}
|
||||
const key = section.key as AiReviewSectionType;
|
||||
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
|
||||
return key;
|
||||
}
|
||||
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
|
||||
return prefix ?? 'students';
|
||||
}
|
||||
|
||||
function sectionCount(section: AiReviewSection): number {
|
||||
return section.rows.length;
|
||||
}
|
||||
|
||||
function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
|
||||
return section.status ?? 'pending';
|
||||
}
|
||||
|
||||
function sectionResultText(section: AiReviewSection): string {
|
||||
if (!section.resultSummary) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string') return parsed.message;
|
||||
} catch {
|
||||
// Older data may store a plain text summary.
|
||||
}
|
||||
return section.resultSummary;
|
||||
}
|
||||
|
||||
const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
|
||||
pending: '待确认',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
|
||||
|
||||
const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
|
||||
pending: '待确认',
|
||||
partial: '部分完成',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
importing: '导入中',
|
||||
};
|
||||
|
||||
function groupSections(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): AiReviewSection[] {
|
||||
return sections.filter((section) => sectionType(section) === type);
|
||||
}
|
||||
|
||||
function groupStatus(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
submittingKey: string | null,
|
||||
submittingGroup: boolean,
|
||||
activeType?: AiReviewSectionType,
|
||||
): GroupStatus {
|
||||
const items = groupSections(sections, type);
|
||||
if (items.length === 0) return 'pending';
|
||||
if (
|
||||
(submittingGroup && type === activeType) ||
|
||||
items.some((item) => submittingKey === item.key)
|
||||
) {
|
||||
return 'importing';
|
||||
}
|
||||
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
|
||||
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function dependencyHint(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): { step: number; title: string } | null {
|
||||
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
|
||||
const matches = groupSections(sections, dependencyType);
|
||||
if (matches.length === 0) {
|
||||
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
|
||||
}
|
||||
for (const section of matches) {
|
||||
if (sectionStatus(section) !== 'submitted') {
|
||||
return { step: sections.indexOf(section), title: section.title };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
if (reason && typeof reason === 'object' && 'message' in reason) {
|
||||
return String((reason as { message?: unknown }).message ?? '确认失败,请稍后重试');
|
||||
}
|
||||
return '确认失败,请稍后重试';
|
||||
}
|
||||
|
||||
function SectionTable({ section }: { section: AiReviewSection }) {
|
||||
const columns: TableProps<AiReviewRow>['columns'] = section.columns.map((column) => ({
|
||||
title: column.title,
|
||||
dataIndex: column.key,
|
||||
key: column.key,
|
||||
ellipsis: true,
|
||||
render: (value: unknown) =>
|
||||
value === null || value === undefined || value === '' ? (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
) : (
|
||||
String(value)
|
||||
),
|
||||
}));
|
||||
return (
|
||||
<Table<AiReviewRow>
|
||||
size="small"
|
||||
rowKey="__rowKey"
|
||||
columns={columns}
|
||||
dataSource={section.rows.map((row, index) => ({ ...row, __rowKey: `row-${index}` }))}
|
||||
pagination={{ pageSize: 10, size: 'small', hideOnSinglePage: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewPreviewProps {
|
||||
review?: AiReviewSchema;
|
||||
disabled?: boolean;
|
||||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onAction }) => {
|
||||
if (!review) return null;
|
||||
const submitted = review.status === 'submitted';
|
||||
const expired = review.status === 'expired';
|
||||
const runtime = review as unknown as {
|
||||
submitting?: boolean;
|
||||
activeKey?: string;
|
||||
activeType?: string;
|
||||
submittingKey?: string | null;
|
||||
submittingGroup?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
const submitting = Boolean(runtime.submitting);
|
||||
const submittingKey = runtime.submittingKey ?? null;
|
||||
const submittingGroup = Boolean(runtime.submittingGroup);
|
||||
const sections = review.sections;
|
||||
const presentTypes = SECTION_ORDER.filter((type) =>
|
||||
sections.some((section) => sectionType(section) === type),
|
||||
);
|
||||
const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType)
|
||||
? (runtime.activeType as AiReviewSectionType)
|
||||
: presentTypes[0];
|
||||
if (!activeType) return null;
|
||||
const activeSection =
|
||||
sections.find((section) => section.key === runtime.activeKey) ??
|
||||
groupSections(sections, activeType)[0];
|
||||
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
|
||||
const dependency =
|
||||
activeSection === undefined ? null : dependencyHint(sections, sectionType(activeSection));
|
||||
const typeItems = presentTypes.map((type, index) => {
|
||||
const items = groupSections(sections, type);
|
||||
const status = groupStatus(sections, type, submittingKey, submittingGroup, activeType);
|
||||
const stepStatus: 'finish' | 'error' | 'process' | 'wait' =
|
||||
status === 'submitted'
|
||||
? 'finish'
|
||||
: status === 'failed'
|
||||
? 'error'
|
||||
: status === 'importing' || type === activeType
|
||||
? 'process'
|
||||
: 'wait';
|
||||
return {
|
||||
key: type,
|
||||
title: `${SECTION_TYPE_LABELS[type]}(${items.reduce((sum, item) => sum + sectionCount(item), 0)})`,
|
||||
content: GROUP_STATUS_LABELS[status],
|
||||
status: stepStatus,
|
||||
index,
|
||||
};
|
||||
});
|
||||
const group = groupSections(sections, activeType);
|
||||
const typeTotal = group.reduce((sum, section) => sum + sectionCount(section), 0);
|
||||
const groupDep = dependencyHint(sections, activeType);
|
||||
const groupReady =
|
||||
!submitted &&
|
||||
!expired &&
|
||||
!disabled &&
|
||||
!submitting &&
|
||||
!submittingKey &&
|
||||
!submittingGroup &&
|
||||
group.length > 0 &&
|
||||
!group.every((section) => sectionStatus(section) === 'submitted') &&
|
||||
!groupDep;
|
||||
const anyRunning = submitting || Boolean(submittingKey) || submittingGroup;
|
||||
const allIssues = sections.flatMap((section) => section.issues);
|
||||
const allRows = sections.reduce((sum, section) => sum + sectionCount(section), 0);
|
||||
|
||||
return (
|
||||
<div className="ai-chat-review-card">
|
||||
<Flex justify="space-between" align="center" wrap gap={8}>
|
||||
<Typography.Text strong className="ai-chat-review-card__title">
|
||||
{review.title}
|
||||
</Typography.Text>
|
||||
{submitted ? (
|
||||
<Tag color="success">已导入</Tag>
|
||||
) : expired ? (
|
||||
<Tag>已失效</Tag>
|
||||
) : anyRunning ? (
|
||||
<Tag color="processing">导入中</Tag>
|
||||
) : (
|
||||
<Tag color="gold">待确认</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
{review.summary && (
|
||||
<Typography.Paragraph type="secondary" className="ai-chat-review-card__summary">
|
||||
{review.summary}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{expired && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="此导入预览已被新的预览替代,已失效"
|
||||
description="如需导入,请使用最新的预览卡。"
|
||||
/>
|
||||
)}
|
||||
<Steps
|
||||
size="small"
|
||||
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
|
||||
items={typeItems.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
status: item.status,
|
||||
}))}
|
||||
onChange={(index) => {
|
||||
const type = typeItems[index]?.key;
|
||||
if (type) onAction?.('review:selectType', { type });
|
||||
}}
|
||||
/>
|
||||
{activeType && (
|
||||
<Flex vertical gap={8} className="ai-chat-review-card__group">
|
||||
<Flex justify="space-between" align="center" wrap gap={8}>
|
||||
<Flex vertical gap={2}>
|
||||
<Typography.Text strong>
|
||||
{SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{GROUP_STATUS_LABELS[
|
||||
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
|
||||
]}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{groupDep && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
groupDep.step === -1
|
||||
? `「${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}」`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!submitted && !expired && group.length > 0 && (
|
||||
<Popconfirm
|
||||
title={`确认导入本组 ${group.length} 张表?`}
|
||||
description={`本组共 ${typeTotal} 行,确认后将按顺序逐表导入。`}
|
||||
okText="确认导入"
|
||||
cancelText="取消"
|
||||
disabled={!groupReady}
|
||||
onConfirm={() =>
|
||||
onAction?.('review:confirmGroup', {
|
||||
reviewId: review.id,
|
||||
type: activeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submittingGroup}
|
||||
disabled={!groupReady}
|
||||
>
|
||||
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
|
||||
'submitted'
|
||||
? '已导入'
|
||||
: `确认本组 ${group.length} 张表`}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex vertical gap={8} className="ai-chat-review-card__sheets">
|
||||
{group.map((section, index) => {
|
||||
const status = sectionStatus(section);
|
||||
const dep = dependencyHint(sections, sectionType(section));
|
||||
const canConfirm =
|
||||
!submitted &&
|
||||
!expired &&
|
||||
!disabled &&
|
||||
!anyRunning &&
|
||||
status !== 'submitted' &&
|
||||
status !== 'skipped' &&
|
||||
!dep;
|
||||
return (
|
||||
<Flex
|
||||
key={section.key}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap
|
||||
gap={8}
|
||||
className="ai-chat-review-card__sheet"
|
||||
onClick={() =>
|
||||
onAction?.('review:selectStep', { sectionKey: section.key })
|
||||
}
|
||||
>
|
||||
<Flex vertical gap={2} style={{ minWidth: 160 }}>
|
||||
<Typography.Text>
|
||||
{index + 1}. {section.title}
|
||||
{section.sheet ? (
|
||||
<Typography.Text type="secondary">({section.sheet})</Typography.Text>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{sectionCount(section)} 行 · {SECTION_STATUS_LABELS[status]}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Button
|
||||
size="small"
|
||||
loading={submittingKey === section.key}
|
||||
disabled={!canConfirm}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAction?.('review:confirmStep', {
|
||||
reviewId: review.id,
|
||||
sectionKey: section.key,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status === 'failed'
|
||||
? '重试导入本步'
|
||||
: status === 'submitted'
|
||||
? '已导入'
|
||||
: status === 'skipped'
|
||||
? '已跳过'
|
||||
: expired
|
||||
? '已失效'
|
||||
: '确认导入本步'}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
{activeSection && (
|
||||
<Flex vertical gap={8} className="ai-chat-review-card__step">
|
||||
{activeSection.issues.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`${activeSection.title}:${activeSection.issues.length} 条待处理`}
|
||||
description={
|
||||
<ul className="ai-chat-review__issues">
|
||||
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
|
||||
<li key={issueIndex}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SectionTable section={activeSection} />
|
||||
{dependency && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
dependency.step === -1
|
||||
? `「${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}」`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeStatus === 'failed' && (
|
||||
<Alert type="error" showIcon message="本步导入失败,可重试" />
|
||||
)}
|
||||
{activeSection.resultSummary && activeStatus === 'submitted' && (
|
||||
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
|
||||
{sectionResultText(activeSection)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
|
||||
<Typography.Text type="secondary">
|
||||
共 {allRows} 行,含 {allIssues.length} 条提示
|
||||
</Typography.Text>
|
||||
{!submitted && !expired && (
|
||||
<Popconfirm
|
||||
title={`确认导入全部 ${sections.length} 张表?`}
|
||||
description={`全部共 ${allRows} 行,将按类型与依赖顺序逐表导入。`}
|
||||
okText="确认导入"
|
||||
cancelText="取消"
|
||||
disabled={submitting || anyRunning || disabled}
|
||||
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={disabled || anyRunning}
|
||||
>
|
||||
全部确认并入库
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flex>
|
||||
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
|
||||
{runtime.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.error}
|
||||
className="ai-chat-review-card__step-error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DynamicReviewProps {
|
||||
review: AiReviewSchema;
|
||||
disabled?: boolean;
|
||||
messageId?: number;
|
||||
onSubmit: (reviewId: string) => void | Promise<void>;
|
||||
onConfirmStep?: (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
sectionKey: string,
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
onConfirmGroup?: (
|
||||
messageId: number | undefined,
|
||||
reviewId: string,
|
||||
type: AiReviewSectionType,
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-import review card rendered through the official A2UI renderer
|
||||
* (@ant-design/x-card). Sections are grouped by business type; each sheet is
|
||||
* confirmed independently, the whole type group can be confirmed together, or
|
||||
* everything can be confirmed in one flow.
|
||||
*/
|
||||
export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
review,
|
||||
disabled,
|
||||
messageId,
|
||||
onSubmit,
|
||||
onConfirmStep,
|
||||
onConfirmGroup,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
|
||||
const [submittingGroup, setSubmittingGroup] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
|
||||
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
|
||||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setLocalReview(review);
|
||||
const types = SECTION_ORDER.filter((type) =>
|
||||
review.sections.some((section) => sectionType(section) === type),
|
||||
);
|
||||
const preferredType =
|
||||
activeType && types.includes(activeType) ? activeType : types[0];
|
||||
setActiveType(preferredType);
|
||||
setActiveKey((current) =>
|
||||
current &&
|
||||
review.sections.some(
|
||||
(section) => section.key === current && sectionType(section) === preferredType,
|
||||
)
|
||||
? current
|
||||
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
|
||||
);
|
||||
}, [activeType, review]);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(localReview.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/review',
|
||||
value: {
|
||||
...localReview,
|
||||
submitting,
|
||||
activeKey,
|
||||
activeType,
|
||||
submittingKey,
|
||||
submittingGroup,
|
||||
error,
|
||||
},
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ReviewPreview',
|
||||
review: { path: '/review' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
|
||||
|
||||
const handleSubmit = async (reviewId: string) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(reviewId);
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmStep = async (reviewId: string, sectionKey: string) => {
|
||||
if (submittingKey) return;
|
||||
setSubmittingKey(sectionKey);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await onConfirmStep?.(messageId, reviewId, sectionKey);
|
||||
if (updated) setLocalReview(updated);
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setSubmittingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmGroup = async (reviewId: string, type: AiReviewSectionType) => {
|
||||
if (submittingGroup) return;
|
||||
setSubmittingGroup(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await onConfirmGroup?.(messageId, reviewId, type);
|
||||
if (updated) setLocalReview(updated);
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
} finally {
|
||||
setSubmittingGroup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (payload: ActionPayload) => {
|
||||
const context = payload.context ?? {};
|
||||
if (payload.name === 'review:submit') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
void handleSubmit(reviewId);
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:selectType') {
|
||||
const type = context.type as AiReviewSectionType | undefined;
|
||||
if (type && SECTION_ORDER.includes(type)) {
|
||||
setActiveType(type);
|
||||
setActiveKey(
|
||||
localReview.sections.find((section) => sectionType(section) === type)?.key,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:selectStep') {
|
||||
if (typeof context.sectionKey === 'string') {
|
||||
const section = localReview.sections.find(
|
||||
(item) => item.key === context.sectionKey,
|
||||
);
|
||||
setActiveKey(context.sectionKey);
|
||||
if (section) setActiveType(sectionType(section));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:confirmStep') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
if (typeof context.sectionKey === 'string') {
|
||||
void handleConfirmStep(reviewId, context.sectionKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:confirmGroup') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const type = context.type as AiReviewSectionType | undefined;
|
||||
if (type && SECTION_ORDER.includes(type)) {
|
||||
void handleConfirmGroup(reviewId, type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-chat-review">
|
||||
<XCard.Box
|
||||
components={{ ReviewPreview }}
|
||||
commands={commands}
|
||||
onAction={handleAction}
|
||||
>
|
||||
<XCard.Card id={surfaceId(localReview.id)} />
|
||||
</XCard.Box>
|
||||
{error && <Alert type="error" showIcon message={error} className="ai-chat-review__error" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicReview;
|
||||
@@ -37,4 +37,38 @@ describe('AI chat API adapter', () => {
|
||||
const page = await aiChatApi.listMessages(3);
|
||||
expect(page.items.map((item) => item.id)).toEqual([1, 101]);
|
||||
});
|
||||
|
||||
it('confirmReviewStep posts to the per-section confirm endpoint', async () => {
|
||||
const updated = {
|
||||
id: 'review-1',
|
||||
title: '批量导入',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{ key: 'students', type: 'students', title: '学生', status: 'submitted' },
|
||||
],
|
||||
};
|
||||
vi.spyOn(api, 'post').mockResolvedValue({ success: true, data: updated });
|
||||
|
||||
await expect(aiChatApi.confirmReviewStep('review-1', 'students')).resolves.toEqual(updated);
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
'/ai/chat/reviews/review-1/steps/students/confirm',
|
||||
);
|
||||
});
|
||||
|
||||
it('confirmReviewGroup posts to the per-type confirm endpoint', async () => {
|
||||
const updated = {
|
||||
id: 'review-1',
|
||||
title: '批量导入',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{ key: 'checkins_a', type: 'checkins', title: '入住A', status: 'submitted' },
|
||||
],
|
||||
};
|
||||
vi.spyOn(api, 'post').mockResolvedValue({ success: true, data: updated });
|
||||
|
||||
await expect(aiChatApi.confirmReviewGroup('review-1', 'checkins')).resolves.toEqual(updated);
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
'/ai/chat/reviews/review-1/types/checkins/confirm',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
AiConversation,
|
||||
AiMessageFeedback,
|
||||
AiMessagePage,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionType,
|
||||
AiSkill,
|
||||
} from './types';
|
||||
|
||||
@@ -21,6 +24,8 @@ export const aiChatApi = {
|
||||
input: { title?: string; lockedSkillKey?: string | null },
|
||||
) => (await api.patch<AiApiResponse<AiConversation>>(`${basePath}/${id}`, input)).data,
|
||||
deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),
|
||||
deleteAllConversations: async () =>
|
||||
(await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data,
|
||||
uploadAttachment: async (file: File): Promise<AiAttachment> => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -43,6 +48,24 @@ export const aiChatApi = {
|
||||
{ feedback, reason },
|
||||
)
|
||||
).data,
|
||||
confirmReviewStep: async (
|
||||
reviewId: string,
|
||||
sectionKey: AiReviewSection['key'],
|
||||
): Promise<AiReviewSchema> =>
|
||||
(
|
||||
await api.post<AiApiResponse<AiReviewSchema>>(
|
||||
`/ai/chat/reviews/${reviewId}/steps/${sectionKey}/confirm`,
|
||||
)
|
||||
).data,
|
||||
confirmReviewGroup: async (
|
||||
reviewId: string,
|
||||
type: AiReviewSectionType,
|
||||
): Promise<AiReviewSchema> =>
|
||||
(
|
||||
await api.post<AiApiResponse<AiReviewSchema>>(
|
||||
`/ai/chat/reviews/${reviewId}/types/${type}/confirm`,
|
||||
)
|
||||
).data,
|
||||
listMessages: async (id: number): Promise<AiMessagePage> => {
|
||||
const first = (
|
||||
await api.get<AiApiResponse<AiMessagePage>>(`${basePath}/${id}/messages`, {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Bubble } from '@ant-design/x';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { aiBubbleRoles } from './AiChatDrawer';
|
||||
import type { AiChatMessage } from './types';
|
||||
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
|
||||
import { AiMessageContent } from './AiMessageContent';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
@@ -16,6 +20,13 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('AI chat bubble rendering', () => {
|
||||
it('maps conversation run statuses to list labels', () => {
|
||||
expect(conversationStatusMeta('running')).toEqual({ label: '生成中', color: 'processing' });
|
||||
expect(conversationStatusMeta('done')).toEqual({ label: '已完成', color: 'success' });
|
||||
expect(conversationStatusMeta('error')).toEqual({ label: '失败', color: 'error' });
|
||||
expect(conversationStatusMeta('stopped')).toEqual({ label: '已停止', color: 'default' });
|
||||
});
|
||||
|
||||
it('renders a structured user message instead of passing the object to React', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'user',
|
||||
@@ -32,11 +43,433 @@ describe('AI chat bubble rendering', () => {
|
||||
root?.render(
|
||||
<Bubble.List
|
||||
role={aiBubbleRoles}
|
||||
items={[{ key: 'user-1', role: 'user', status: 'local', content: message }]}
|
||||
items={[
|
||||
{
|
||||
key: 'user-1',
|
||||
role: 'user',
|
||||
status: 'local',
|
||||
content: message,
|
||||
contentRender: (content: AiChatMessage) => <div>{content.content}</div>,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('查询今天的系统概览');
|
||||
});
|
||||
|
||||
it('renders an A2UI form and submits normalized values', async () => {
|
||||
let submitted: Record<string, unknown> | null = null;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DynamicForm
|
||||
form={{
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
submitLabel: '提交创建',
|
||||
fields: [
|
||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
||||
{ name: 'studentNo', label: '学号', type: 'input' },
|
||||
],
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
submitted = values;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('新增学生');
|
||||
const input = container.querySelector('input#name') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
if (input) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
||||
setter?.call(input, '张三');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const submitButton = container.querySelector('button[type="submit"]') as HTMLButtonElement | null;
|
||||
expect(submitButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(submitted).toEqual({ name: '张三' });
|
||||
expect(container.textContent).toContain('已提交');
|
||||
});
|
||||
|
||||
it('renders an A2UI review card and submits via the confirm button', async () => {
|
||||
let submittedId: string | null = null;
|
||||
const review: AiReviewSchema = {
|
||||
id: 'review-1',
|
||||
title: '开学导入',
|
||||
summary: '来自报名 Excel',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'students',
|
||||
type: 'students',
|
||||
title: '学生',
|
||||
kind: 'table',
|
||||
columns: [
|
||||
{ key: 'name', title: '姓名' },
|
||||
{ key: 'phone', title: '手机号' },
|
||||
],
|
||||
rows: [
|
||||
{ name: '张三', phone: '13800138000' },
|
||||
{ name: '李四', phone: '13900139000' },
|
||||
],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DynamicReview
|
||||
review={review}
|
||||
onSubmit={(reviewId) => {
|
||||
submittedId = reviewId;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('开学导入');
|
||||
expect(container.textContent).toContain('确认导入本步');
|
||||
expect(container.textContent).toContain('全部确认并入库');
|
||||
const button = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('全部确认并入库'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(item) => item.textContent?.trim() === '确认导入',
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(confirmButton).toBeDefined();
|
||||
await act(async () => {
|
||||
confirmButton?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
expect(submittedId).toBe('review-1');
|
||||
});
|
||||
|
||||
it('renders grouped sheets and confirms one type group via Popconfirm', async () => {
|
||||
let submittedGroup: { reviewId: string; type: string } | null = null;
|
||||
const review: AiReviewSchema = {
|
||||
id: 'review-2',
|
||||
title: '入住分表',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'checkins_girls_4',
|
||||
type: 'checkins',
|
||||
title: '四人间女',
|
||||
kind: 'table',
|
||||
columns: [
|
||||
{ key: 'name', title: '姓名' },
|
||||
{ key: 'roomNumber', title: '宿舍号' },
|
||||
],
|
||||
rows: [{ name: '张三', roomNumber: '4-401' }],
|
||||
issues: [],
|
||||
},
|
||||
{
|
||||
key: 'checkins_boys_4',
|
||||
type: 'checkins',
|
||||
title: '四人间男',
|
||||
kind: 'table',
|
||||
columns: [
|
||||
{ key: 'name', title: '姓名' },
|
||||
{ key: 'roomNumber', title: '宿舍号' },
|
||||
],
|
||||
rows: [{ name: '李四', roomNumber: '4-402' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DynamicReview
|
||||
review={review}
|
||||
onSubmit={() => undefined}
|
||||
onConfirmGroup={(_, reviewId, type) => {
|
||||
submittedGroup = { reviewId, type };
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('入住记录 · 共 2 张表');
|
||||
expect(container.textContent).toContain('确认本组 2 张表');
|
||||
const groupButton = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('确认本组 2 张表'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(groupButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
groupButton?.click();
|
||||
});
|
||||
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(item) => item.textContent?.trim() === '确认导入',
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(confirmButton).toBeDefined();
|
||||
await act(async () => {
|
||||
confirmButton?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
expect(submittedGroup).toEqual({ reviewId: 'review-2', type: 'checkins' });
|
||||
});
|
||||
|
||||
it('renders legacy review sections missing type by inferring from key', async () => {
|
||||
const review: AiReviewSchema = {
|
||||
id: 'review-3',
|
||||
title: '旧数据预览',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'checkins_legacy',
|
||||
title: '旧入住表',
|
||||
kind: 'table',
|
||||
columns: [{ key: 'name', title: '姓名' }],
|
||||
rows: [{ name: '张三' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<DynamicReview review={review} onSubmit={() => undefined} />);
|
||||
});
|
||||
expect(container.textContent).toContain('入住记录 · 共 1 张表');
|
||||
expect(container.textContent).toContain('旧入住表');
|
||||
});
|
||||
|
||||
it('renders an expired review card with table content but disabled actions', async () => {
|
||||
let confirmed = false;
|
||||
const review: AiReviewSchema = {
|
||||
id: 'review-4',
|
||||
title: '已被替代的预览',
|
||||
status: 'expired',
|
||||
sections: [
|
||||
{
|
||||
key: 'checkins_old',
|
||||
type: 'checkins',
|
||||
title: '旧入住表',
|
||||
kind: 'table',
|
||||
columns: [{ key: 'name', title: '姓名' }],
|
||||
rows: [{ name: '张三' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DynamicReview
|
||||
review={review}
|
||||
onSubmit={() => {
|
||||
confirmed = true;
|
||||
}}
|
||||
onConfirmStep={() => {
|
||||
confirmed = true;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain('已失效');
|
||||
expect(container.textContent).toContain('已被新的预览替代');
|
||||
expect(container.textContent).toContain('张三');
|
||||
expect(container.textContent).not.toContain('全部确认并入库');
|
||||
const disabledButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(item) => item.textContent?.trim() === '已失效',
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(disabledButton).toBeDefined();
|
||||
expect(disabledButton?.disabled).toBe(true);
|
||||
await act(async () => {
|
||||
disabledButton?.click();
|
||||
});
|
||||
expect(confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('renders an A2UI chart card with title and chart container', async () => {
|
||||
const chart: AiChartSchema = {
|
||||
id: 'chart-1',
|
||||
title: '各班级人数',
|
||||
chartType: 'bar',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ className: '一班', count: 20 },
|
||||
{ className: '二班', count: 15 },
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<DynamicChart chart={chart} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('各班级人数');
|
||||
expect(container.textContent).toContain('柱状图');
|
||||
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
||||
expect(container.querySelector('.ai-chat-chart-card__download')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders charts persisted on an assistant message', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是学生性别比例图',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
charts: [
|
||||
{
|
||||
id: 'chart-9',
|
||||
title: '学生性别比例',
|
||||
chartType: 'pie',
|
||||
columns: [
|
||||
{ key: 'gender', title: '性别' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ gender: '男', count: 2 },
|
||||
{ gender: '女', count: 0 },
|
||||
{ gender: '未填写', count: 61 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('学生性别比例');
|
||||
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders source references from assistant message metadata', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是基于你上传的名单整理的入住统计。',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiSources: [
|
||||
{ title: '26暑期文化课宿舍.xlsx', url: '/api/ai/chat/attachments/7', description: 'excel' },
|
||||
],
|
||||
},
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('引用来源');
|
||||
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
||||
});
|
||||
|
||||
it('renders model retrying hint while waiting for the upstream retry', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
retrying: { attempt: 2, maxRetries: 3, reason: '上游返回 503' },
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('正在自动重试(第 2 / 3 次)');
|
||||
expect(container.textContent).toContain('上游返回 503');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['area', '面积图', [{ key: 'month', title: '月份' }, { key: 'amount', title: '金额' }], [
|
||||
{ month: '1月', amount: 100 },
|
||||
{ month: '2月', amount: 150 },
|
||||
]],
|
||||
['scatter', '散点图', [
|
||||
{ key: 'room', title: '宿舍' },
|
||||
{ key: 'capacity', title: '容量' },
|
||||
{ key: 'occupied', title: '入住人数' },
|
||||
], [
|
||||
{ room: '1-101', capacity: 4, occupied: 3 },
|
||||
{ room: '1-102', capacity: 6, occupied: 5 },
|
||||
]],
|
||||
['radar', '雷达图', [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'attendance', title: '考勤' },
|
||||
{ key: 'score', title: '成绩' },
|
||||
], [
|
||||
{ className: '一班', attendance: 90, score: 85 },
|
||||
{ className: '二班', attendance: 80, score: 92 },
|
||||
]],
|
||||
['gauge', '仪表盘', [
|
||||
{ key: 'metric', title: '指标' },
|
||||
{ key: 'value', title: '数值' },
|
||||
{ key: 'max', title: '最大值' },
|
||||
], [
|
||||
{ metric: '入住率', value: 82, max: 100 },
|
||||
]],
|
||||
['funnel', '漏斗图', [
|
||||
{ key: 'stage', title: '阶段' },
|
||||
{ key: 'count', title: '人数' },
|
||||
], [
|
||||
{ stage: '咨询', count: 100 },
|
||||
{ stage: '报名', count: 60 },
|
||||
]],
|
||||
])('渲染 %s 图表卡片', async (chartType, label, columns, rows) => {
|
||||
const chart: AiChartSchema = {
|
||||
id: `chart-${chartType}`,
|
||||
title: `${label}示例`,
|
||||
chartType: chartType as AiChartSchema['chartType'],
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<DynamicChart chart={chart} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(`${label}示例`);
|
||||
expect(container.textContent).toContain(label);
|
||||
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,4 +52,93 @@ describe('AI chat history mapper', () => {
|
||||
expect(mapHistoryMessage({ ...base, status: 'failed' }).status).toBe('error');
|
||||
expect(mapHistoryMessage({ ...base, status: 'cancelled' }).status).toBe('abort');
|
||||
});
|
||||
|
||||
it('restores a persisted A2UI form from message metadata', () => {
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 5,
|
||||
role: 'assistant',
|
||||
content: '请填写表单',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: {
|
||||
a2uiForm: {
|
||||
id: 'form-9',
|
||||
title: '新增学生',
|
||||
submitLabel: '提交创建',
|
||||
status: 'pending',
|
||||
fields: [
|
||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
||||
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped.message.forms).toHaveLength(1);
|
||||
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
|
||||
});
|
||||
|
||||
it('restores a persisted A2UI review from message metadata', () => {
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 6,
|
||||
role: 'assistant',
|
||||
content: '请审阅导入预览',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: {
|
||||
a2uiReview: {
|
||||
id: 'review-9',
|
||||
title: '批量导入',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'transfers',
|
||||
type: 'transfers',
|
||||
title: '换宿',
|
||||
kind: 'table',
|
||||
columns: [{ key: 'newRoom', title: '目标宿舍' }],
|
||||
rows: [{ newRoom: '3-301' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped.message.reviews).toHaveLength(1);
|
||||
expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
|
||||
});
|
||||
|
||||
it('restores persisted A2UI charts from message metadata', () => {
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 7,
|
||||
role: 'assistant',
|
||||
content: '这是图表',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: {
|
||||
a2uiChart: [
|
||||
{
|
||||
id: 'chart-9',
|
||||
title: '各班级人数',
|
||||
chartType: 'bar',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [{ className: '一班', count: 20 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapped.message.charts).toHaveLength(1);
|
||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { MessageInfo } from '@ant-design/x-sdk';
|
||||
import type { AiChatMessage, AiChatMessageStatus, AiMessageRecord, AiToolRun } from './types';
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiMessageRecord,
|
||||
AiReviewSchema,
|
||||
AiToolRun,
|
||||
} from './types';
|
||||
|
||||
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
|
||||
if (record.status === 'pending') return 'loading';
|
||||
@@ -16,6 +24,32 @@ function normalizeToolRun(tool: AiToolRun): AiToolRun {
|
||||
};
|
||||
}
|
||||
|
||||
function historyForms(record: AiMessageRecord): AiFormSchema[] | undefined {
|
||||
const a2uiForm = record.metadata?.a2uiForm;
|
||||
if (!a2uiForm || typeof a2uiForm !== 'object' || Array.isArray(a2uiForm)) return undefined;
|
||||
return [a2uiForm as AiFormSchema];
|
||||
}
|
||||
|
||||
function historyReviews(record: AiMessageRecord): AiReviewSchema[] | undefined {
|
||||
const a2uiReview = record.metadata?.a2uiReview;
|
||||
if (!a2uiReview || typeof a2uiReview !== 'object' || Array.isArray(a2uiReview)) {
|
||||
return undefined;
|
||||
}
|
||||
return [a2uiReview as AiReviewSchema];
|
||||
}
|
||||
|
||||
function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
|
||||
const a2uiChart = record.metadata?.a2uiChart;
|
||||
if (Array.isArray(a2uiChart)) {
|
||||
return a2uiChart.filter(
|
||||
(item): item is AiChartSchema =>
|
||||
Boolean(item) && typeof item === 'object' && typeof (item as AiChartSchema).id === 'string',
|
||||
);
|
||||
}
|
||||
if (!a2uiChart || typeof a2uiChart !== 'object') return undefined;
|
||||
return [a2uiChart as AiChartSchema];
|
||||
}
|
||||
|
||||
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -27,6 +61,9 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
|
||||
reasoningContent: record.reasoningContent || '',
|
||||
toolRuns: (record.toolRuns || []).map(normalizeToolRun),
|
||||
attachments: record.attachments ?? [],
|
||||
forms: historyForms(record),
|
||||
reviews: historyReviews(record),
|
||||
charts: historyCharts(record),
|
||||
replyToMessageId: record.replyToMessageId,
|
||||
feedback: record.feedback,
|
||||
feedbackReason: record.feedbackReason,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSsePayload, reduceAiSseMessage } from './provider';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
authenticatedFetch,
|
||||
GongxueAiChatProvider,
|
||||
parseSsePayload,
|
||||
reduceAiSseMessage,
|
||||
} from './provider';
|
||||
|
||||
describe('AI chat SSE message reducer', () => {
|
||||
it('separates reasoning and answer deltas', () => {
|
||||
@@ -120,6 +125,362 @@ describe('AI chat SSE message reducer', () => {
|
||||
expect(message.id).toBe(9);
|
||||
});
|
||||
|
||||
it('merges ui.form events into the assistant message by id', () => {
|
||||
const form = {
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
submitLabel: '提交创建',
|
||||
fields: [
|
||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
||||
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: 'male' }] },
|
||||
],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({ messageId: 8, form }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({ messageId: 8, form: { ...form, id: 'form-1' } }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.form',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
form: { id: 'form-2', title: '入住确认', fields: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.forms).toHaveLength(2);
|
||||
expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' });
|
||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
||||
});
|
||||
|
||||
it('restores a persisted form from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '请填写表单',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiForm: {
|
||||
id: 'form-9',
|
||||
title: '新增学生',
|
||||
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.forms).toHaveLength(1);
|
||||
expect(message.forms?.[0].id).toBe('form-9');
|
||||
});
|
||||
|
||||
it('merges ui.review events into the assistant message and updates by id', () => {
|
||||
const review = {
|
||||
id: 'review-1',
|
||||
title: '开学导入',
|
||||
summary: '来自报名 Excel',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'students',
|
||||
type: 'students',
|
||||
title: '学生',
|
||||
kind: 'table',
|
||||
columns: [
|
||||
{ key: 'name', title: '姓名' },
|
||||
{ key: 'phone', title: '手机号' },
|
||||
],
|
||||
rows: [{ name: '张三', phone: '13800138000' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.review',
|
||||
data: JSON.stringify({ messageId: 8, review }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.review',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
review: { ...review, status: 'submitted', resultSummary: '{"students":{"created":1}}' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.reviews).toHaveLength(1);
|
||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'submitted' });
|
||||
});
|
||||
|
||||
it('shows model retrying state and clears it when content starts', () => {
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'model.retrying',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
retry: { attempt: 1, maxRetries: 3, delayMs: 500, reason: '上游返回 503' },
|
||||
}),
|
||||
});
|
||||
expect(message.retrying).toMatchObject({ attempt: 1, maxRetries: 3 });
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'content.delta',
|
||||
data: JSON.stringify({ messageId: 8, delta: '你好' }),
|
||||
});
|
||||
expect(message.retrying).toBeNull();
|
||||
expect(message.content).toContain('你好');
|
||||
});
|
||||
|
||||
it('restores a persisted review from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '请审阅',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiReview: {
|
||||
id: 'review-9',
|
||||
title: '批量导入',
|
||||
status: 'pending',
|
||||
sections: [
|
||||
{
|
||||
key: 'rooms',
|
||||
type: 'rooms',
|
||||
title: '宿舍',
|
||||
kind: 'table',
|
||||
columns: [{ key: 'roomNumber', title: '房间号' }],
|
||||
rows: [{ roomNumber: '3-301' }],
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.reviews).toHaveLength(1);
|
||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-9', title: '批量导入' });
|
||||
});
|
||||
|
||||
it('merges ui.chart events into the assistant message by id', () => {
|
||||
const chart = {
|
||||
id: 'chart-1',
|
||||
title: '各班级人数',
|
||||
chartType: 'bar',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ className: '一班', count: 20 },
|
||||
{ className: '二班', count: 15 },
|
||||
],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.chart',
|
||||
data: JSON.stringify({ messageId: 8, chart }),
|
||||
});
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'ui.chart',
|
||||
data: JSON.stringify({
|
||||
messageId: 8,
|
||||
chart: { ...chart, id: 'chart-2', title: '女生人数' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.charts).toHaveLength(2);
|
||||
expect(message.charts?.[0]).toMatchObject({ id: 'chart-1', chartType: 'bar' });
|
||||
expect(message.charts?.[1]).toMatchObject({ id: 'chart-2' });
|
||||
});
|
||||
|
||||
it('restores persisted charts from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 12,
|
||||
content: '这是图表',
|
||||
status: 'completed',
|
||||
metadata: {
|
||||
a2uiChart: [
|
||||
{
|
||||
id: 'chart-9',
|
||||
title: '男女比例',
|
||||
chartType: 'pie',
|
||||
columns: [
|
||||
{ key: 'name', title: '性别' },
|
||||
{ key: 'value', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ name: '男', value: 20 },
|
||||
{ name: '女', value: 15 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(message.charts).toHaveLength(1);
|
||||
expect(message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'pie' });
|
||||
});
|
||||
|
||||
it('rewrites review submissions to the review submit stream endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '确认批量导入',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
reviewSubmission: { reviewId: 'review-1', reviewTitle: '开学导入' },
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/reviews/review-1/submit/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoningEffort when rewriting regenerate requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
regenerateMessageId: 99,
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/conversations/3/messages/99/regenerate/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoningEffort when rewriting form submissions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
await authenticatedFetch('http://x/api/ai/chat/conversations/3/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '',
|
||||
attachmentIds: [],
|
||||
skillKey: null,
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
reasoningEffort: 'high',
|
||||
formSubmission: { formId: 'form-1', values: { name: '张三' } },
|
||||
}),
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'http://x/api/ai/chat/forms/form-1/submit/stream',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
clientRequestId: '6a8bc680-3cb5-4f2d-85ee-974974e0f194',
|
||||
values: { name: '张三' },
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes submit-time ui.review to the original message instead of the streaming one', () => {
|
||||
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
||||
const onExternalReview = vi.fn();
|
||||
provider.onExternalReview = onExternalReview;
|
||||
const review = {
|
||||
id: 'review-1',
|
||||
title: '批量导入',
|
||||
status: 'submitted',
|
||||
sections: [],
|
||||
};
|
||||
const origin = {
|
||||
id: 13,
|
||||
role: 'assistant' as const,
|
||||
content: '生成中',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
reviews: [],
|
||||
};
|
||||
const next = provider.transformMessage({
|
||||
originMessage: origin,
|
||||
chunk: { event: 'ui.review', data: JSON.stringify({ messageId: 12, review }) },
|
||||
status: 'updating',
|
||||
chunks: [],
|
||||
responseHeaders: {} as Headers,
|
||||
});
|
||||
|
||||
expect(onExternalReview).toHaveBeenCalledWith(12, review);
|
||||
expect(next).toBe(origin);
|
||||
expect(next.reviews ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes ui.review without an origin message to the external handler', () => {
|
||||
const provider = new GongxueAiChatProvider('http://x/api/ai/chat/conversations/3/stream');
|
||||
const onExternalReview = vi.fn();
|
||||
provider.onExternalReview = onExternalReview;
|
||||
const next = provider.transformMessage({
|
||||
chunk: {
|
||||
event: 'ui.review',
|
||||
data: JSON.stringify({
|
||||
messageId: 12,
|
||||
review: { id: 'review-1', title: '批量导入', status: 'submitted', sections: [] },
|
||||
}),
|
||||
},
|
||||
status: 'updating',
|
||||
chunks: [],
|
||||
responseHeaders: {} as Headers,
|
||||
});
|
||||
|
||||
expect(onExternalReview).toHaveBeenCalledWith(
|
||||
12,
|
||||
expect.objectContaining({ id: 'review-1' }),
|
||||
);
|
||||
expect(next.reviews ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tolerates non-JSON event data', () => {
|
||||
expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({
|
||||
event: 'content.delta',
|
||||
|
||||
@@ -4,10 +4,16 @@ import {
|
||||
type TransformMessage,
|
||||
type XRequestOptions,
|
||||
} 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';
|
||||
@@ -26,6 +32,10 @@ interface AiSsePayload {
|
||||
summary?: string | null;
|
||||
durationMs?: number | null;
|
||||
attachment?: AiAttachment;
|
||||
form?: AiFormSchema;
|
||||
review?: AiReviewSchema;
|
||||
chart?: AiChartSchema;
|
||||
retry?: AiModelRetryInfo;
|
||||
message?:
|
||||
| string
|
||||
| {
|
||||
@@ -50,9 +60,63 @@ function emptyAssistant(): AiChatMessage {
|
||||
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 mergeReviews(
|
||||
current: AiReviewSchema[] | undefined,
|
||||
incoming: AiReviewSchema | AiReviewSchema[] | undefined,
|
||||
): AiReviewSchema[] {
|
||||
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;
|
||||
}
|
||||
|
||||
function mergeCharts(
|
||||
current: AiChartSchema[] | undefined,
|
||||
incoming: AiChartSchema | AiChartSchema[] | undefined,
|
||||
): AiChartSchema[] {
|
||||
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;
|
||||
@@ -115,14 +179,36 @@ export function reduceAiSseMessage(
|
||||
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
|
||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||
message.attachments = nested?.attachments ?? message.attachments;
|
||||
message.forms = mergeForms(
|
||||
message.forms,
|
||||
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||||
);
|
||||
message.reviews = mergeReviews(
|
||||
message.reviews,
|
||||
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||||
);
|
||||
message.charts = mergeCharts(
|
||||
message.charts,
|
||||
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||||
);
|
||||
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
|
||||
message.feedback = nested?.feedback ?? message.feedback;
|
||||
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
|
||||
message.metadata = nested?.metadata ?? message.metadata;
|
||||
} 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 = mergeReviews(message.reviews, payload.review);
|
||||
} else if (event === 'ui.chart' && payload.chart) {
|
||||
message.charts = mergeCharts(message.charts, payload.chart);
|
||||
} else if (event === 'tool.started') {
|
||||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
|
||||
} else if (event === 'tool.completed') {
|
||||
@@ -141,14 +227,29 @@ export function reduceAiSseMessage(
|
||||
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
|
||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||
message.attachments = nested?.attachments ?? message.attachments;
|
||||
message.forms = mergeForms(
|
||||
message.forms,
|
||||
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||||
);
|
||||
message.reviews = mergeReviews(
|
||||
message.reviews,
|
||||
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||||
);
|
||||
message.charts = mergeCharts(
|
||||
message.charts,
|
||||
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||||
);
|
||||
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
|
||||
message.feedback = nested?.feedback ?? message.feedback;
|
||||
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
|
||||
message.metadata = nested?.metadata ?? message.metadata;
|
||||
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 ||
|
||||
@@ -157,9 +258,12 @@ export function reduceAiSseMessage(
|
||||
return message;
|
||||
}
|
||||
|
||||
async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
export async function authenticatedFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.set('Accept', 'text/event-stream');
|
||||
let requestInput = input;
|
||||
@@ -171,13 +275,37 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
|
||||
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;
|
||||
requestInit = {
|
||||
...init,
|
||||
body: JSON.stringify({ clientRequestId: body.clientRequestId }),
|
||||
body: JSON.stringify({
|
||||
clientRequestId: body.clientRequestId,
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
}),
|
||||
};
|
||||
} else if (body.formSubmission) {
|
||||
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`;
|
||||
requestInit = {
|
||||
...init,
|
||||
body: JSON.stringify({
|
||||
clientRequestId: body.clientRequestId,
|
||||
values: body.formSubmission.values,
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
}),
|
||||
};
|
||||
} else if (body.reviewSubmission) {
|
||||
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/reviews/${body.reviewSubmission.reviewId}/submit/stream`;
|
||||
requestInit = {
|
||||
...init,
|
||||
body: JSON.stringify({
|
||||
clientRequestId: body.clientRequestId,
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
const {
|
||||
localAttachments: _localAttachments,
|
||||
reloadMessage: _reloadMessage,
|
||||
regenerateMessageId: _regenerateMessageId,
|
||||
formSubmission: _formSubmission,
|
||||
reviewSubmission: _reviewSubmission,
|
||||
...payload
|
||||
} = body;
|
||||
requestInit = { ...init, body: JSON.stringify(payload) };
|
||||
@@ -188,9 +316,8 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
|
||||
}
|
||||
const response = await fetch(requestInput, { ...requestInit, headers });
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return response;
|
||||
@@ -201,17 +328,27 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
||||
AiChatInput,
|
||||
AiSseChunk
|
||||
> {
|
||||
constructor(url: string, onSettled?: () => void) {
|
||||
/** Routes events that target another (already streamed) message. */
|
||||
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void,
|
||||
) {
|
||||
super({
|
||||
request: XRequest<AiChatInput, AiSseChunk, AiChatMessage>(url, {
|
||||
manual: true,
|
||||
fetch: authenticatedFetch,
|
||||
timeout: 15_000,
|
||||
streamTimeout: 120_000,
|
||||
streamTimeout: 1_800_000,
|
||||
callbacks: {
|
||||
onUpdate: () => undefined,
|
||||
onSuccess: () => onSettled?.(),
|
||||
onError: () => onSettled?.(),
|
||||
onSuccess: () => onSettled?.({ ok: true }),
|
||||
onError: (error) =>
|
||||
onSettled?.({
|
||||
ok: false,
|
||||
aborted: error?.name === 'AbortError',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -227,13 +364,44 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
||||
attachmentIds: requestParams.attachmentIds ?? [],
|
||||
skillKey: requestParams.skillKey ?? null,
|
||||
clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),
|
||||
reasoningEffort: requestParams.reasoningEffort,
|
||||
localAttachments: requestParams.localAttachments,
|
||||
formSubmission: requestParams.formSubmission,
|
||||
reviewSubmission: requestParams.reviewSubmission,
|
||||
regenerateMessageId: requestParams.regenerateMessageId,
|
||||
reloadMessage: requestParams.reloadMessage,
|
||||
};
|
||||
}
|
||||
|
||||
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage {
|
||||
if (requestParams.formSubmission) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: requestParams.localAttachments ?? [],
|
||||
metadata: {
|
||||
a2uiSubmit: {
|
||||
formTitle: requestParams.formSubmission.formTitle,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (requestParams.reviewSubmission) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: requestParams.localAttachments ?? [],
|
||||
metadata: {
|
||||
a2uiReviewSubmit: {
|
||||
reviewTitle: requestParams.reviewSubmission.reviewTitle,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: 'user',
|
||||
content: requestParams.message?.trim() || '',
|
||||
@@ -244,6 +412,18 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
||||
}
|
||||
|
||||
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
||||
const { event, payload } = parseSsePayload(info.chunk);
|
||||
if (
|
||||
event === 'ui.review' &&
|
||||
payload.review &&
|
||||
typeof payload.messageId === 'number' &&
|
||||
info.originMessage?.id !== payload.messageId
|
||||
) {
|
||||
// The submitted review belongs to the original assistant message;
|
||||
// do not merge it into the message currently being streamed.
|
||||
this.onExternalReview?.(payload.messageId, payload.review);
|
||||
return info.originMessage ?? emptyAssistant();
|
||||
}
|
||||
return reduceAiSseMessage(info.originMessage, info.chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-sender-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-chat-sender-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-chat-sender-footer .ant-sender-switch {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.ai-chat-layout {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -18,6 +36,8 @@
|
||||
|
||||
.ai-chat-sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
@@ -35,7 +55,9 @@
|
||||
|
||||
.ai-chat-sidebar .ant-conversations {
|
||||
width: 232px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -43,11 +65,98 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-label__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-check {
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
|
||||
/* 运行中指示:使用 antd LoadingOutlined 旋转图标 */
|
||||
.ai-chat-conversation-loading {
|
||||
color: #007aff;
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ai-chat-streaming-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #007aff;
|
||||
font-size: 16px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
/* 失败 / 已停止:极简状态文字,不再使用 Tag */
|
||||
.ai-chat-conversation-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-state i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-state.is-error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-state.is-error i {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-state.is-stopped {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.ai-chat-conversation-state.is-stopped i {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar__loading {
|
||||
position: absolute;
|
||||
inset: 68px 0 auto;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar__footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar__selected-count {
|
||||
margin-right: auto;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-chat-main {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -143,7 +252,7 @@
|
||||
border-top: 1px solid #ededf0;
|
||||
}
|
||||
|
||||
.ai-chat-composer .ant-sender {
|
||||
.ai-chat-composer > .ant-sender {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -159,6 +268,16 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-chat-composer .ant-sender-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ai-chat-composer .ant-sender-prefix .ant-btn {
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.ai-chat-disclaimer {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
@@ -191,3 +310,125 @@
|
||||
max-width: 92%;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-chat-dynamic-form {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.ai-chat-dynamic-form__desc {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-dynamic-form .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-dynamic-form__number,
|
||||
.ai-chat-dynamic-form__date {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-chat-dynamic-form__error {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-review {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-chat-review-card {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__summary {
|
||||
margin: 4px 0 8px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__footer {
|
||||
margin-top: 6px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__step {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__group {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__sheets {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__sheet {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__sheet:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__step-result {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-review-card__step-error {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-review__issues {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-review__error {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-chart {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-chat-chart-card {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-chat-chart-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,73 @@ export interface AiAttachment {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiFormFieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AiFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'textarea' | 'number' | 'select' | 'date';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: AiFormFieldOption[];
|
||||
}
|
||||
|
||||
export interface AiFormSchema {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
submitLabel?: string;
|
||||
fields: AiFormField[];
|
||||
status?: 'pending' | 'submitted';
|
||||
}
|
||||
|
||||
export interface AiReviewColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AiReviewRow {
|
||||
[key: string]: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export type AiReviewSectionStatus = 'pending' | 'submitted' | 'failed' | 'skipped';
|
||||
export type AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins';
|
||||
|
||||
export interface AiReviewSection {
|
||||
key: string;
|
||||
type?: AiReviewSectionType;
|
||||
title: string;
|
||||
kind: 'table';
|
||||
sheet?: string;
|
||||
columns: AiReviewColumn[];
|
||||
rows: AiReviewRow[];
|
||||
issues: string[];
|
||||
status?: AiReviewSectionStatus;
|
||||
resultSummary?: string | null;
|
||||
submittedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AiReviewSchema {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
sections: AiReviewSection[];
|
||||
status?: 'pending' | 'submitted' | 'expired';
|
||||
resultSummary?: string | null;
|
||||
}
|
||||
|
||||
export interface AiChartSchema {
|
||||
id: string;
|
||||
title: string;
|
||||
chartType: 'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'gauge' | 'funnel';
|
||||
columns: AiReviewColumn[];
|
||||
rows: AiReviewRow[];
|
||||
}
|
||||
|
||||
export type AiToolRunStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
@@ -51,6 +118,13 @@ export interface AiToolRun {
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
export interface AiModelRetryInfo {
|
||||
attempt: number;
|
||||
maxRetries: number;
|
||||
delayMs?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type AiMessageRole = 'user' | 'assistant';
|
||||
export type AiMessageFeedback = 'like' | 'dislike' | null;
|
||||
|
||||
@@ -61,10 +135,14 @@ export interface AiChatMessage {
|
||||
reasoningContent: string;
|
||||
toolRuns: AiToolRun[];
|
||||
attachments: AiAttachment[];
|
||||
forms?: AiFormSchema[];
|
||||
reviews?: AiReviewSchema[];
|
||||
charts?: AiChartSchema[];
|
||||
replyToMessageId?: number | null;
|
||||
feedback?: AiMessageFeedback;
|
||||
feedbackReason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
retrying?: AiModelRetryInfo | null;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
@@ -97,7 +175,17 @@ export interface AiChatInput {
|
||||
attachmentIds: number[];
|
||||
skillKey: string | null;
|
||||
clientRequestId: string;
|
||||
reasoningEffort?: string | null;
|
||||
localAttachments?: AiAttachment[];
|
||||
formSubmission?: {
|
||||
formId: string;
|
||||
values: Record<string, unknown>;
|
||||
formTitle?: string;
|
||||
};
|
||||
reviewSubmission?: {
|
||||
reviewId: string;
|
||||
reviewTitle?: string;
|
||||
};
|
||||
regenerateMessageId?: number;
|
||||
reloadMessage?: AiChatMessage;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,14 @@ import { Navigate } from 'react-router-dom';
|
||||
import { Result, Spin } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
const DefaultRoute: React.FC = () => {
|
||||
const { permissions, permissionsReady } = usePermission();
|
||||
if (!permissionsReady) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
}
|
||||
const roles = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts/core';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
export type EChartsOption = Record<string, unknown>;
|
||||
import { BarChart, CustomChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import {
|
||||
BarChart,
|
||||
CustomChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
ScatterChart,
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
} from 'echarts/components';
|
||||
@@ -14,11 +25,16 @@ import { CanvasRenderer } from 'echarts/renderers';
|
||||
echarts.use([
|
||||
BarChart,
|
||||
CustomChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
ScatterChart,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
RadarComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
CanvasRenderer,
|
||||
@@ -28,15 +44,20 @@ interface EChartsProps {
|
||||
option: EChartsOption;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
/** 图表实例就绪回调(用于导出图片等场景) */
|
||||
onReady?: (chart: EChartsType) => void;
|
||||
}
|
||||
|
||||
const ECharts: React.FC<EChartsProps> = ({ option, style, className }) => {
|
||||
const ECharts: React.FC<EChartsProps> = ({ option, style, className, onReady }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const chart = echarts.init(containerRef.current);
|
||||
chart.setOption(option);
|
||||
onReadyRef.current?.(chart);
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(containerRef.current);
|
||||
return () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BellOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
interface NotificationItem {
|
||||
id: number;
|
||||
@@ -56,7 +57,7 @@ const NotificationBell: React.FC = () => {
|
||||
// SSE connection — decoupled from popover open state
|
||||
useEffect(() => {
|
||||
fetchUnread();
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) return;
|
||||
const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);
|
||||
es.onmessage = (event) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Result, Button, Spin } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
interface PermissionRouteProps {
|
||||
permission: string;
|
||||
@@ -11,17 +12,12 @@ interface PermissionRouteProps {
|
||||
|
||||
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
|
||||
const { permissions, permissionsReady, hasPermission } = usePermission();
|
||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||
const navigate = useNavigate();
|
||||
if (!permissionsReady) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
}
|
||||
if (!hasPermission(permission)) {
|
||||
let roles: string[] = [];
|
||||
try {
|
||||
roles = JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
roles = [];
|
||||
}
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
return (
|
||||
<Result
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import {
|
||||
@@ -12,13 +12,7 @@ import { Tabs } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||
|
||||
const STORAGE_KEY = 'gongxue-route-dock';
|
||||
|
||||
interface DockTab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
import { useAppStore } from '../../store';
|
||||
|
||||
interface RouteDockProps {
|
||||
location: Location;
|
||||
@@ -50,19 +44,6 @@ function getRouteLabel(items: readonly AppMenuItem[], pathname: string): string
|
||||
return pathname === '/' ? '首页' : '页面';
|
||||
}
|
||||
|
||||
function readStoredTabs(): DockTab[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(tab): tab is DockTab =>
|
||||
typeof tab?.key === 'string' && tab.key.startsWith('/') && typeof tab?.label === 'string',
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props['data-node-key'],
|
||||
@@ -87,28 +68,20 @@ const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props
|
||||
|
||||
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
|
||||
const activeKey = `${location.pathname}${location.search}`;
|
||||
const [tabs, setTabs] = useState<DockTab[]>(() => {
|
||||
const storedTabs = readStoredTabs();
|
||||
if (location.pathname === '/') return storedTabs;
|
||||
if (storedTabs.some((tab) => tab.key === activeKey)) return storedTabs;
|
||||
return [...storedTabs, { key: activeKey, label: getRouteLabel(menuItems, location.pathname) }];
|
||||
});
|
||||
const tabs = useAppStore((state) => state.routeDockTabs);
|
||||
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') return;
|
||||
setTabs((currentTabs) => {
|
||||
setRouteDockTabs((currentTabs) => {
|
||||
const label = getRouteLabel(menuItems, location.pathname);
|
||||
const existing = currentTabs.find((tab) => tab.key === activeKey);
|
||||
if (!existing) return [...currentTabs, { key: activeKey, label }];
|
||||
if (existing.label === label) return currentTabs;
|
||||
return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab));
|
||||
});
|
||||
}, [activeKey, location.pathname, menuItems]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
}, [tabs]);
|
||||
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
||||
|
||||
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
||||
() =>
|
||||
@@ -124,7 +97,7 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
|
||||
const targetIndex = tabs.findIndex((tab) => tab.key === targetKey);
|
||||
if (targetIndex < 0 || tabs.length === 1) return;
|
||||
const nextTabs = tabs.filter((tab) => tab.key !== targetKey);
|
||||
setTabs(nextTabs);
|
||||
setRouteDockTabs(nextTabs);
|
||||
if (targetKey === activeKey) {
|
||||
const nextActiveTab = nextTabs[Math.min(targetIndex, nextTabs.length - 1)];
|
||||
if (nextActiveTab) onNavigate(nextActiveTab.key);
|
||||
@@ -133,7 +106,7 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
setTabs((currentTabs) => {
|
||||
setRouteDockTabs((currentTabs) => {
|
||||
const activeIndex = currentTabs.findIndex((tab) => tab.key === active.id);
|
||||
const overIndex = currentTabs.findIndex((tab) => tab.key === over.id);
|
||||
return activeIndex < 0 || overIndex < 0
|
||||
|
||||
96
apps/admin/src/components/RouteKeeper.integration.test.tsx
Normal file
96
apps/admin/src/components/RouteKeeper.integration.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { RouteKeeper } from './RouteKeeper';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function PageA() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
<input data-testid="input-a" aria-label="A 输入" />
|
||||
<button data-testid="go-b" onClick={() => navigate('/b')}>
|
||||
去B
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageB() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
<input data-testid="input-b" aria-label="B 输入" />
|
||||
<button data-testid="go-a" onClick={() => navigate('/')}>
|
||||
去A
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route path="/" element={<RouteKeeper />}>
|
||||
<Route index element={<PageA />} />
|
||||
<Route path="b" element={<PageB />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function type(target: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
setter?.call(target, value);
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('RouteKeeper', () => {
|
||||
it('keeps page instances and input values alive across navigation', async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => root?.render(<Harness />));
|
||||
|
||||
const inputA = document.querySelector('[data-testid="input-a"]') as HTMLInputElement;
|
||||
expect(inputA).not.toBeNull();
|
||||
await act(async () => type(inputA, '待保存的学生姓名'));
|
||||
|
||||
await act(async () => {
|
||||
(document.querySelector('[data-testid="go-b"]') as HTMLButtonElement).click();
|
||||
});
|
||||
const inputB = document.querySelector('[data-testid="input-b"]') as HTMLInputElement;
|
||||
expect(inputB).not.toBeNull();
|
||||
await act(async () => type(inputB, '待保存的宿舍号'));
|
||||
|
||||
await act(async () => {
|
||||
(document.querySelector('[data-testid="go-a"]') as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
const keptA = document.querySelector('[data-testid="input-a"]') as HTMLInputElement;
|
||||
expect(keptA).not.toBeNull();
|
||||
expect(keptA.value).toBe('待保存的学生姓名');
|
||||
const keptB = document.querySelector('[data-testid="input-b"]') as HTMLInputElement;
|
||||
expect(keptB.value).toBe('待保存的宿舍号');
|
||||
|
||||
const pages = document.querySelectorAll('.route-keeper-page');
|
||||
expect(pages.length).toBe(2);
|
||||
const hidden = pages[1] as HTMLElement;
|
||||
expect(hidden.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
43
apps/admin/src/components/RouteKeeper.tsx
Normal file
43
apps/admin/src/components/RouteKeeper.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useLocation, useOutlet } from 'react-router-dom';
|
||||
|
||||
const MAX_CACHED_PAGES = 30;
|
||||
|
||||
/**
|
||||
* 路由保活:切换页面时保留已访问页面的组件实例(输入、滚动、弹窗状态不丢失)。
|
||||
* 隐藏页面仍挂载在 DOM 中,仅通过 display:none 隐藏。
|
||||
*/
|
||||
export const RouteKeeper: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const outlet = useOutlet();
|
||||
const cacheRef = useRef<Map<string, React.ReactNode>>(new Map());
|
||||
const orderRef = useRef<string[]>([]);
|
||||
// 仅以 pathname 作为缓存键:页面内部通过 URL 参数同步状态时不会
|
||||
// 产生第二个实例,切回时也不会因此重挂载。
|
||||
const pageKey = location.pathname;
|
||||
|
||||
if (outlet && !cacheRef.current.has(pageKey)) {
|
||||
cacheRef.current.set(pageKey, outlet);
|
||||
orderRef.current.push(pageKey);
|
||||
if (orderRef.current.length > MAX_CACHED_PAGES) {
|
||||
const oldest = orderRef.current.shift();
|
||||
if (oldest && oldest !== pageKey) cacheRef.current.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(cacheRef.current.entries()).map(([key, node]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="route-keeper-page"
|
||||
style={{ display: key === pageKey ? undefined : 'none' }}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteKeeper;
|
||||
@@ -1,19 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PERMISSIONS_UPDATED_EVENT, readPermissionState } from '../auth/permission-store';
|
||||
import { useCallback } from 'react';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
|
||||
export function usePermission() {
|
||||
const [state, setState] = useState(readPermissionState);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setState(readPermissionState());
|
||||
window.addEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
|
||||
return () => {
|
||||
window.removeEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const permissions = state.permissions;
|
||||
const permissionsReady = state.status === 'ready';
|
||||
const permissions = usePermissionStore((state) => state.permissions);
|
||||
const permissionStatus = usePermissionStore((state) => state.status);
|
||||
const permissionsReady = permissionStatus === 'ready';
|
||||
const hasPermission = useCallback(
|
||||
(code: string): boolean => permissionsReady && permissions.includes(code),
|
||||
[permissions, permissionsReady],
|
||||
@@ -31,7 +22,7 @@ export function usePermission() {
|
||||
|
||||
return {
|
||||
permissions,
|
||||
permissionStatus: state.status,
|
||||
permissionStatus,
|
||||
permissionsReady,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
@@ -28,16 +28,16 @@ import {
|
||||
TrophyOutlined,
|
||||
ApiOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
import {
|
||||
beginPermissionVerification,
|
||||
clearPermissions,
|
||||
writePermissions,
|
||||
} from '../auth/permission-store';
|
||||
import { useAppStore } from '../store/app/appStore';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import RouteDock from '../components/RouteDock';
|
||||
import RouteKeeper from '../components/RouteKeeper';
|
||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||
|
||||
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
|
||||
@@ -75,16 +75,22 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [aiChatOpen, setAiChatOpen] = useState(false);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
const prevPathname = useRef('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() =>
|
||||
JSON.parse(localStorage.getItem('user') || '{}'),
|
||||
);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const updateUser = useUserStore((state) => state.updateUser);
|
||||
const logoutUser = useUserStore((state) => state.logout);
|
||||
const collapsed = useAppStore((state) => state.sidebarCollapsed);
|
||||
const drawerOpen = useAppStore((state) => state.mobileDrawerOpen);
|
||||
const aiChatOpen = useAppStore((state) => state.aiChatOpen);
|
||||
const aiWorking = useAppStore((state) => state.aiWorking);
|
||||
const openKeys = useAppStore((state) => state.menuOpenKeys);
|
||||
const toggleSidebar = useAppStore((state) => state.toggleSidebar);
|
||||
const setDrawerOpen = useAppStore((state) => state.setMobileDrawerOpen);
|
||||
const setAiChatOpen = useAppStore((state) => state.setAiChatOpen);
|
||||
const setAiWorking = useAppStore((state) => state.setAiWorking);
|
||||
const setOpenKeys = useAppStore((state) => state.setMenuOpenKeys);
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,13 +99,13 @@ const MainLayout: React.FC = () => {
|
||||
let verificationInFlight = false;
|
||||
|
||||
const verifyPermissions = () => {
|
||||
if (cancelled || verificationInFlight || !localStorage.getItem('token')) return;
|
||||
if (cancelled || verificationInFlight || !useUserStore.getState().token) return;
|
||||
if (retryTimer !== undefined) {
|
||||
window.clearTimeout(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
verificationInFlight = true;
|
||||
beginPermissionVerification();
|
||||
usePermissionStore.getState().beginPermissionVerification();
|
||||
api
|
||||
.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>(
|
||||
'/auth/profile',
|
||||
@@ -107,22 +113,19 @@ const MainLayout: React.FC = () => {
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
verificationInFlight = false;
|
||||
writePermissions(profile.permissions || []);
|
||||
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const nextUser = { ...cachedUser, ...profile };
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
setUser(nextUser);
|
||||
usePermissionStore.getState().writePermissions(profile.permissions || []);
|
||||
updateUser(profile);
|
||||
})
|
||||
.catch(() => {
|
||||
verificationInFlight = false;
|
||||
if (cancelled || !localStorage.getItem('token')) return;
|
||||
if (cancelled || !useUserStore.getState().token) return;
|
||||
retryTimer = window.setTimeout(verifyPermissions, 5_000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== 'token' && event.key !== 'permissions') return;
|
||||
beginPermissionVerification();
|
||||
usePermissionStore.getState().beginPermissionVerification();
|
||||
window.location.reload();
|
||||
};
|
||||
const handleOnline = () => verifyPermissions();
|
||||
@@ -141,7 +144,7 @@ const MainLayout: React.FC = () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [updateUser]);
|
||||
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm; // < 576px (仅 xs)
|
||||
@@ -150,16 +153,15 @@ const MainLayout: React.FC = () => {
|
||||
const usesDrawer = !isDesktop;
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => buildMenu(user.roles ?? [], permissions),
|
||||
[user.roles, permissions],
|
||||
() => buildMenu(user?.roles ?? [], permissions),
|
||||
[user, permissions],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
clearPermissions();
|
||||
logoutUser();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
navigate('/login');
|
||||
}, [navigate]);
|
||||
}, [logoutUser, navigate]);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(key: string) => {
|
||||
@@ -303,17 +305,25 @@ const MainLayout: React.FC = () => {
|
||||
<MenuFoldOutlined />
|
||||
)
|
||||
}
|
||||
onClick={() => (usesDrawer ? setDrawerOpen(true) : setCollapsed(!collapsed))}
|
||||
onClick={() => (usesDrawer ? setDrawerOpen(true) : toggleSidebar())}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
{hasPermission('ai:chat:use') && (
|
||||
<Tooltip title="AI 助理">
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="打开 AI 助理"
|
||||
icon={<RobotOutlined style={{ fontSize: 17 }} />}
|
||||
onClick={() => setAiChatOpen(true)}
|
||||
/>
|
||||
<Tooltip title={aiWorking ? 'AI 处理中' : 'AI 助理'}>
|
||||
<Badge dot={aiWorking} color="#007AFF" offset={[-3, 5]}>
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={aiWorking ? 'AI 助理处理中' : '打开 AI 助理'}
|
||||
icon={
|
||||
aiWorking ? (
|
||||
<LoadingOutlined style={{ fontSize: 17, color: '#007AFF' }} spin />
|
||||
) : (
|
||||
<RobotOutlined style={{ fontSize: 17 }} />
|
||||
)
|
||||
}
|
||||
onClick={() => setAiChatOpen(true)}
|
||||
/>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasPermission('notification:view') && <NotificationBell />}
|
||||
@@ -336,7 +346,7 @@ const MainLayout: React.FC = () => {
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
{isDesktop && <span>{user.name || user.username || '用户'}</span>}
|
||||
{isDesktop && <span>{user?.name || user?.username || '用户'}</span>}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -356,12 +366,16 @@ const MainLayout: React.FC = () => {
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
<RouteKeeper />
|
||||
</Content>
|
||||
</Layout>
|
||||
{hasPermission('ai:chat:use') && aiChatOpen && (
|
||||
{hasPermission('ai:chat:use') && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AiChatDrawer open onClose={() => setAiChatOpen(false)} />
|
||||
<AiChatDrawer
|
||||
open={aiChatOpen}
|
||||
onClose={() => setAiChatOpen(false)}
|
||||
onRequestingChange={setAiWorking}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -60,6 +60,7 @@ interface AiConfigData {
|
||||
enabled: boolean;
|
||||
supportsVision: boolean;
|
||||
timeoutMs: number;
|
||||
reasoningEffort: string | null;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
lastTestLatencyMs: number | null;
|
||||
@@ -99,6 +100,7 @@ interface FormValues {
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
supportsVision: boolean;
|
||||
reasoningEffort: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_VALUES: FormValues = {
|
||||
@@ -108,6 +110,7 @@ const DEFAULT_FORM_VALUES: FormValues = {
|
||||
defaultModel: '',
|
||||
timeoutMs: 30000,
|
||||
supportsVision: false,
|
||||
reasoningEffort: '',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,6 +174,7 @@ const AiConfigPage: React.FC = () => {
|
||||
defaultModel: res.data.defaultModel ?? '',
|
||||
timeoutMs: res.data.timeoutMs,
|
||||
supportsVision: res.data.supportsVision,
|
||||
reasoningEffort: res.data.reasoningEffort ?? '',
|
||||
};
|
||||
form.setFieldsValue(initial);
|
||||
setFormValues(initial);
|
||||
@@ -252,7 +256,8 @@ const AiConfigPage: React.FC = () => {
|
||||
// Validate fields (for UI error display) — actual values come from state
|
||||
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
|
||||
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision } = formValues;
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision, reasoningEffort } =
|
||||
formValues;
|
||||
|
||||
if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) {
|
||||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||||
@@ -270,6 +275,7 @@ const AiConfigPage: React.FC = () => {
|
||||
enabled: true,
|
||||
supportsVision,
|
||||
timeoutMs,
|
||||
reasoningEffort: reasoningEffort || null,
|
||||
};
|
||||
|
||||
if (apiKey && apiKey !== '••••') {
|
||||
@@ -308,11 +314,12 @@ const AiConfigPage: React.FC = () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs, reasoningEffort } = formValues;
|
||||
const body: Record<string, unknown> = { provider, timeoutMs };
|
||||
if (baseUrl) body.baseUrl = baseUrl;
|
||||
if (defaultModel) body.defaultModel = defaultModel;
|
||||
if (apiKey && apiKey !== '••••') body.apiKey = apiKey;
|
||||
if (reasoningEffort) body.reasoningEffort = reasoningEffort;
|
||||
|
||||
const res = await api.post<TestResult>('/ai/config/test', body);
|
||||
setTestResult(res);
|
||||
@@ -582,6 +589,25 @@ const AiConfigPage: React.FC = () => {
|
||||
<Switch disabled={!canWrite} checkedChildren="已启用" unCheckedChildren="未启用" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reasoningEffort"
|
||||
label="推理强度 (reasoning_effort)"
|
||||
extra="OpenAI o 系列等支持该参数的模型生效;DeepSeek 官方接口不支持,选择后也不会发送。"
|
||||
preserve
|
||||
>
|
||||
<Select
|
||||
disabled={!canWrite}
|
||||
size="large"
|
||||
options={[
|
||||
{ value: '', label: '不设置(跟随模型默认)' },
|
||||
{ value: 'low', label: '低 (low)' },
|
||||
{ value: 'medium', label: '中 (medium)' },
|
||||
{ value: 'high', label: '高 (high)' },
|
||||
{ value: 'xhigh', label: '极高 (xhigh)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{config?.verified && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
|
||||
@@ -38,6 +38,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
@@ -184,12 +185,8 @@ const EMPTY_SUMMARY: AttendanceSummary = {
|
||||
};
|
||||
|
||||
function readCurrentRoles(): string[] {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] };
|
||||
return Array.isArray(user.roles) ? user.roles : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roles = useUserStore.getState().user?.roles;
|
||||
return Array.isArray(roles) ? roles : [];
|
||||
}
|
||||
|
||||
function displayAttendanceStatus(status?: string | null): string {
|
||||
@@ -732,7 +729,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
const handleExport = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value));
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`/api/attendance-records/export?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import {
|
||||
Card,
|
||||
Tabs,
|
||||
@@ -572,7 +573,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`/api/classes/${id}/roster/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
@@ -28,9 +28,16 @@ export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => {
|
||||
displayName && displayName !== user.username
|
||||
? `${displayName}(${user.username})`
|
||||
: user.username;
|
||||
const roleNames = [...new Set((user.roles || []).map((role) => role.name).filter(Boolean))];
|
||||
const roleNames = [
|
||||
...new Set((user.roles || []).flatMap((role) => (role.name ? [role.name] : []))),
|
||||
];
|
||||
const subjects = [
|
||||
...new Set((user.profile?.subjects || []).map((subject) => subject.trim()).filter(Boolean)),
|
||||
...new Set(
|
||||
(user.profile?.subjects || []).flatMap((subject) => {
|
||||
const trimmed = subject.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · ');
|
||||
|
||||
@@ -368,12 +368,12 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{detailModal.startDate} ~ {detailModal.endDate}(
|
||||
{dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}天)
|
||||
</div>
|
||||
{detailModal.dailyRate && (
|
||||
{detailModal.dailyRate != null && (
|
||||
<div>
|
||||
<strong>日租金:</strong>¥{detailModal.dailyRate}
|
||||
</div>
|
||||
)}
|
||||
{detailModal.totalAmount && (
|
||||
{detailModal.totalAmount != null && (
|
||||
<div>
|
||||
<strong>合同总额:</strong>¥{detailModal.totalAmount}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可用', color: 'green' },
|
||||
@@ -143,7 +144,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
@@ -389,7 +390,7 @@ const ClassroomsPage: React.FC = () => {
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const baseURL = '/api';
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`${baseURL}/classrooms/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ const DashboardPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const [s, rr, cr, g] = await Promise.all([
|
||||
const [s, rr, cr, g, co, cu] = await Promise.all([
|
||||
api.get<DashboardStats>('/dashboard/stats'),
|
||||
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
@@ -220,14 +220,14 @@ const DashboardPage: React.FC = () => {
|
||||
api.get<GanttRoom[]>('/dashboard/gantt', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
}),
|
||||
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
|
||||
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
|
||||
]);
|
||||
setStats(s);
|
||||
setRoomRanking(rr);
|
||||
setClassRanking(cr);
|
||||
setGanttData(g);
|
||||
const co = await api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy');
|
||||
setClassroomOccupancy(co);
|
||||
const cu = await api.get<ClassroomUtilStats>('/dashboard/classroom-utilization');
|
||||
setClassroomUtil(cu);
|
||||
loadedRef.current = true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,14 +4,18 @@ import { Form, Input, Button, Card, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { clearPermissions, writePermissions } from '../../auth/permission-store';
|
||||
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
||||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const setSession = useUserStore((state) => state.setSession);
|
||||
const clearPermissions = usePermissionStore((state) => state.clearPermissions);
|
||||
const writePermissions = usePermissionStore((state) => state.writePermissions);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
@@ -19,8 +23,7 @@ const LoginPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/auth/login', values);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
setSession(res.access_token, res.user);
|
||||
const permissions = res.user.permissions || [];
|
||||
writePermissions(permissions);
|
||||
message.success('登录成功');
|
||||
@@ -33,7 +36,7 @@ const LoginPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
[clearPermissions, navigate, setSession, writePermissions],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,6 +49,14 @@ function timeAgo(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
const FILTER_ITEMS: Array<{ key: string; icon: React.ReactNode; label: string }> = [
|
||||
{ key: 'all', icon: <BellOutlined />, label: '全部' },
|
||||
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
|
||||
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
|
||||
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
|
||||
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
|
||||
];
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
@@ -101,14 +109,6 @@ const NotificationsPage: React.FC = () => {
|
||||
const filtered =
|
||||
filter === 'all' ? notifications : notifications.filter((n) => n.type === filter);
|
||||
|
||||
const filterItems = [
|
||||
{ key: 'all', icon: <BellOutlined />, label: '全部' },
|
||||
{ key: 'bill_generated', icon: <DollarOutlined />, label: '账单' },
|
||||
{ key: 'check_in', icon: <HomeOutlined />, label: '入住' },
|
||||
{ key: 'class_change', icon: <TeamOutlined />, label: '班级' },
|
||||
{ key: 'announcement', icon: <SettingOutlined />, label: '公告' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout className="notifications-layout" style={{ minHeight: '100%', background: '#fff' }}>
|
||||
{!isMobile && (
|
||||
@@ -117,7 +117,7 @@ const NotificationsPage: React.FC = () => {
|
||||
mode="inline"
|
||||
selectedKeys={[filter]}
|
||||
onClick={({ key }) => setFilter(key)}
|
||||
items={filterItems}
|
||||
items={FILTER_ITEMS}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
@@ -132,7 +132,7 @@ const NotificationsPage: React.FC = () => {
|
||||
<Select
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
options={filterItems.map((item) => ({ value: item.key, label: item.label }))}
|
||||
options={FILTER_ITEMS.map((item) => ({ value: item.key, label: item.label }))}
|
||||
className="notifications-filter"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -132,8 +132,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
selectedBatchRecords
|
||||
.map((item) => item.checkInDate)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1),
|
||||
.reduce((latest: string | undefined, date) =>
|
||||
!latest || date > latest ? date : latest,
|
||||
undefined),
|
||||
[selectedBatchRecords],
|
||||
);
|
||||
const latestSelectedBillingStartDate = useMemo(
|
||||
@@ -141,8 +142,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
selectedBatchRecords
|
||||
.map((item) => item.billingStartDate || item.checkInDate)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1),
|
||||
.reduce((latest: string | undefined, date) =>
|
||||
!latest || date > latest ? date : latest,
|
||||
undefined),
|
||||
[selectedBatchRecords],
|
||||
);
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ const RoomsPage: React.FC = () => {
|
||||
|
||||
// 获取楼栋列表用于筛选
|
||||
const buildings = useMemo(() => {
|
||||
const set = new Set(data.map((r: any) => r.building).filter(Boolean));
|
||||
const set = new Set(data.flatMap((r: any) => (r.building ? [r.building] : [])));
|
||||
return [...set].sort();
|
||||
}, [data]);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SwapOutlined,
|
||||
SyncOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -39,6 +40,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { selectArchiveRecords } from '../archive-view';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -79,6 +81,17 @@ interface StudentUpdateImportResult {
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
interface DingTalkSyncLog {
|
||||
status: string;
|
||||
recordsCount: number;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
interface DingTalkSyncResult {
|
||||
synced: number;
|
||||
logs: DingTalkSyncLog[];
|
||||
}
|
||||
|
||||
interface StudentFilterLookups {
|
||||
classes: Array<{ id: number; name: string; code?: string }>;
|
||||
teachers: Array<{ id: number; name: string; username: string }>;
|
||||
@@ -98,6 +111,7 @@ const StudentsPage: React.FC = () => {
|
||||
const canEditStudent = hasPermission('student:edit');
|
||||
const canDeleteStudent = hasPermission('student:delete');
|
||||
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -114,7 +128,9 @@ const StudentsPage: React.FC = () => {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [dingSyncLoading, setDingSyncLoading] = useState(false);
|
||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||
const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 });
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||||
const [form] = Form.useForm();
|
||||
@@ -336,7 +352,7 @@ const StudentsPage: React.FC = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
@@ -441,11 +457,33 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDingTalkSync = async () => {
|
||||
setDingSyncLoading(true);
|
||||
try {
|
||||
const res = await api.post<DingTalkSyncResult>('/sync/trigger', null, {
|
||||
params: { platform: 'dingtalk_students', createMissing: false, updateProfile: false },
|
||||
timeout: 120000,
|
||||
});
|
||||
const log = res.logs?.[0];
|
||||
if (log?.status === 'partial') {
|
||||
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
|
||||
} else {
|
||||
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`);
|
||||
}
|
||||
await fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '钉钉同步失败');
|
||||
} finally {
|
||||
setDingSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
const params = new URLSearchParams();
|
||||
if (searchName) params.set('name', searchName);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
@@ -469,7 +507,13 @@ const StudentsPage: React.FC = () => {
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 70,
|
||||
render: (_: unknown, __: unknown, index: number) =>
|
||||
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
@@ -741,6 +785,7 @@ const StudentsPage: React.FC = () => {
|
||||
saveCell,
|
||||
hasPermission,
|
||||
canChooseOrganization,
|
||||
pageInfo,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -904,6 +949,11 @@ const StudentsPage: React.FC = () => {
|
||||
同步金数据
|
||||
</Button>
|
||||
) : null}
|
||||
{!showArchived && canSyncDingTalk ? (
|
||||
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={handleDingTalkSync}>
|
||||
同步钉钉
|
||||
</Button>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -920,6 +970,23 @@ const StudentsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
{selectedRowKeys.length > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<span>
|
||||
已选 <strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong> 人(支持跨页勾选)
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
@@ -940,9 +1007,12 @@ const StudentsPage: React.FC = () => {
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
current: pageInfo.current,
|
||||
pageSize: pageInfo.pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
onChange: (current, pageSize) => setPageInfo({ current, pageSize }),
|
||||
}}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
|
||||
67
apps/admin/src/store/app/appStore.ts
Normal file
67
apps/admin/src/store/app/appStore.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { appUiPersistStorage, APP_UI_STORAGE_NAME } from '../middleware/persist';
|
||||
import type { AppPersistedState, AppStore } from './appTypes';
|
||||
|
||||
/**
|
||||
* 应用级 UI Store:布局、抽屉、路由页签等跨组件共享状态。
|
||||
* 侧边栏折叠与路由页签持久化;其余为会话内状态。
|
||||
*/
|
||||
export const useAppStore = create<AppStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarCollapsed: false,
|
||||
mobileDrawerOpen: false,
|
||||
menuOpenKeys: [],
|
||||
aiChatOpen: false,
|
||||
aiWorking: false,
|
||||
routeDockTabs: [],
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), false, 'app/toggleSidebar');
|
||||
},
|
||||
setSidebarCollapsed: (collapsed) => {
|
||||
set({ sidebarCollapsed: collapsed }, false, 'app/setSidebarCollapsed');
|
||||
},
|
||||
setMobileDrawerOpen: (open) => {
|
||||
set({ mobileDrawerOpen: open }, false, 'app/setMobileDrawerOpen');
|
||||
},
|
||||
setMenuOpenKeys: (keys) => {
|
||||
set(
|
||||
(state) => ({
|
||||
menuOpenKeys: typeof keys === 'function' ? keys(state.menuOpenKeys) : keys,
|
||||
}),
|
||||
false,
|
||||
'app/setMenuOpenKeys',
|
||||
);
|
||||
},
|
||||
setAiChatOpen: (open) => {
|
||||
set({ aiChatOpen: open }, false, 'app/setAiChatOpen');
|
||||
},
|
||||
setAiWorking: (working) => {
|
||||
set({ aiWorking: working }, false, 'app/setAiWorking');
|
||||
},
|
||||
setRouteDockTabs: (updater) => {
|
||||
set(
|
||||
(state) => ({
|
||||
routeDockTabs:
|
||||
typeof updater === 'function' ? updater(state.routeDockTabs) : updater,
|
||||
}),
|
||||
false,
|
||||
'app/setRouteDockTabs',
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: APP_UI_STORAGE_NAME,
|
||||
storage: appUiPersistStorage,
|
||||
partialize: (state): AppPersistedState => ({
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
routeDockTabs: state.routeDockTabs,
|
||||
}),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
{ name: 'app-store', enabled: import.meta.env.DEV },
|
||||
),
|
||||
);
|
||||
39
apps/admin/src/store/app/appTypes.ts
Normal file
39
apps/admin/src/store/app/appTypes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface DockTab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
/** 桌面端侧边栏折叠 */
|
||||
sidebarCollapsed: boolean;
|
||||
/** 移动端导航抽屉 */
|
||||
mobileDrawerOpen: boolean;
|
||||
/** 菜单展开的分组 key */
|
||||
menuOpenKeys: string[];
|
||||
/** AI 助理抽屉 */
|
||||
aiChatOpen: boolean;
|
||||
/** AI 请求处理中 */
|
||||
aiWorking: boolean;
|
||||
/** 路由页签(RouteDock) */
|
||||
routeDockTabs: DockTab[];
|
||||
}
|
||||
|
||||
export interface AppActions {
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setMobileDrawerOpen: (open: boolean) => void;
|
||||
/** 函数式更新,与 setState 语义一致 */
|
||||
setMenuOpenKeys: (keys: string[] | ((current: string[]) => string[])) => void;
|
||||
setAiChatOpen: (open: boolean) => void;
|
||||
setAiWorking: (working: boolean) => void;
|
||||
/** 函数式更新,与 setState 语义一致 */
|
||||
setRouteDockTabs: (updater: DockTab[] | ((current: DockTab[]) => DockTab[])) => void;
|
||||
}
|
||||
|
||||
export type AppStore = AppState & AppActions;
|
||||
|
||||
/** 持久化子集 */
|
||||
export interface AppPersistedState {
|
||||
sidebarCollapsed: boolean;
|
||||
routeDockTabs: DockTab[];
|
||||
}
|
||||
26
apps/admin/src/store/index.ts
Normal file
26
apps/admin/src/store/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 全局状态统一出口。
|
||||
* 业务代码从这里或对应 Store 文件导入,避免散落的局部状态管理。
|
||||
*/
|
||||
export { useAppStore } from './app/appStore';
|
||||
export { usePermissionStore } from './permission/permissionStore';
|
||||
export { useSettingsStore } from './settings/settingsStore';
|
||||
export { useUserStore } from './user/userStore';
|
||||
|
||||
export type { AppActions, AppPersistedState, AppState, AppStore, DockTab } from './app/appTypes';
|
||||
export type {
|
||||
PermissionActions,
|
||||
PermissionPersistedState,
|
||||
PermissionState,
|
||||
PermissionStatus,
|
||||
PermissionStore,
|
||||
} from './permission/permissionTypes';
|
||||
export type { AiChatSettings, SettingsActions, SettingsState, SettingsStore } from './settings/settingsTypes';
|
||||
export type { StoreStatus } from './types';
|
||||
export type {
|
||||
UserActions,
|
||||
UserInfo,
|
||||
UserPersistedState,
|
||||
UserState,
|
||||
UserStore,
|
||||
} from './user/userTypes';
|
||||
175
apps/admin/src/store/middleware/persist.ts
Normal file
175
apps/admin/src/store/middleware/persist.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 持久化中间件基础设施。
|
||||
*
|
||||
* 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key
|
||||
* (token / user / permissions / gongxue-route-dock)包装成 zustand
|
||||
* persist 的 StateStorage,保证迁移前后数据格式兼容。
|
||||
*/
|
||||
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
|
||||
import type { AppPersistedState, DockTab } from '../app/appTypes';
|
||||
import type { PermissionPersistedState } from '../permission/permissionTypes';
|
||||
import type { UserInfo, UserPersistedState } from '../user/userTypes';
|
||||
|
||||
export const AUTH_STORAGE_NAME = 'gongxue-auth';
|
||||
export const LEGACY_TOKEN_KEY = 'token';
|
||||
export const LEGACY_USER_KEY = 'user';
|
||||
|
||||
export const PERMISSION_STORAGE_NAME = 'permissions';
|
||||
|
||||
export const APP_UI_STORAGE_NAME = 'gongxue-app-ui';
|
||||
export const LEGACY_DOCK_STORAGE_KEY = 'gongxue-route-dock';
|
||||
|
||||
export const SETTINGS_STORAGE_NAME = 'gongxue-settings';
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isDockTab(value: unknown): value is DockTab {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.key === 'string' &&
|
||||
value.key.startsWith('/') &&
|
||||
typeof value.label === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户会话持久化:继续使用旧的 `token` / `user` 两个 key,
|
||||
* 保持与后端、既有代码及浏览器缓存格式一致。
|
||||
*/
|
||||
const legacyAuthStorage: StateStorage = {
|
||||
getItem: () => {
|
||||
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
|
||||
if (token === null && rawUser === null) return null;
|
||||
let user: UserInfo | null = null;
|
||||
if (rawUser !== null) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawUser);
|
||||
user = isRecord(parsed) ? (parsed as UserInfo) : null;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ state: { token, user }, version: 1 });
|
||||
},
|
||||
setItem: (_name, value) => {
|
||||
try {
|
||||
const persisted = JSON.parse(value) as { state?: UserPersistedState };
|
||||
const { token, user } = persisted.state ?? {};
|
||||
if (token) {
|
||||
localStorage.setItem(LEGACY_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
}
|
||||
if (user) {
|
||||
localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem(LEGACY_USER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// 持久化写入失败不应影响应用运行
|
||||
}
|
||||
},
|
||||
removeItem: () => {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
localStorage.removeItem(LEGACY_USER_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。
|
||||
* 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed,
|
||||
* 直到 `/auth/profile` 校验成功。
|
||||
*/
|
||||
const legacyPermissionStorage: StateStorage = {
|
||||
getItem: () => {
|
||||
const raw = localStorage.getItem(PERMISSION_STORAGE_NAME);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return JSON.stringify({
|
||||
state: { permissions: parsed.filter(isString), status: 'unknown' },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
if (isRecord(parsed) && isRecord(parsed.state)) {
|
||||
const permissions = Array.isArray(parsed.state.permissions)
|
||||
? parsed.state.permissions.filter(isString)
|
||||
: [];
|
||||
return JSON.stringify({
|
||||
state: { permissions, status: 'unknown' },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 损坏的缓存按无权限处理
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (_name, value) => {
|
||||
try {
|
||||
const persisted = JSON.parse(value) as { state?: PermissionPersistedState };
|
||||
const permissions = Array.isArray(persisted.state?.permissions)
|
||||
? persisted.state.permissions.filter(isString)
|
||||
: [];
|
||||
localStorage.setItem(PERMISSION_STORAGE_NAME, JSON.stringify(permissions));
|
||||
} catch {
|
||||
// 忽略损坏数据
|
||||
}
|
||||
},
|
||||
removeItem: () => {
|
||||
localStorage.removeItem(PERMISSION_STORAGE_NAME);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用 UI 状态持久化:新 key `gongxue-app-ui`,
|
||||
* 首次读取时自动迁移旧 key `gongxue-route-dock` 中已打开的页签。
|
||||
*/
|
||||
const appUiStorage: StateStorage = {
|
||||
getItem: (name) => {
|
||||
const current = localStorage.getItem(name);
|
||||
if (current) return current;
|
||||
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
|
||||
if (!legacy) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(legacy);
|
||||
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
|
||||
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
try {
|
||||
localStorage.setItem(name, value);
|
||||
} catch {
|
||||
// 持久化失败不应影响应用运行
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
try {
|
||||
localStorage.removeItem(name);
|
||||
} catch {
|
||||
// 持久化失败不应影响应用运行
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** 会话 Store 使用的 persist storage(兼容旧 token/user key) */
|
||||
export const authPersistStorage = createJSONStorage(() => legacyAuthStorage);
|
||||
|
||||
/** 权限 Store 使用的 persist storage(兼容旧 permissions key) */
|
||||
export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage);
|
||||
|
||||
/** 应用 UI Store 使用的 persist storage(含旧 RouteDock key 迁移) */
|
||||
export const appUiPersistStorage = createJSONStorage(() => appUiStorage);
|
||||
|
||||
export type { AppPersistedState, PermissionPersistedState, UserPersistedState };
|
||||
48
apps/admin/src/store/permission/permissionStore.ts
Normal file
48
apps/admin/src/store/permission/permissionStore.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
permissionPersistStorage,
|
||||
PERMISSION_STORAGE_NAME,
|
||||
} from '../middleware/persist';
|
||||
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
|
||||
|
||||
/**
|
||||
* 权限 Store。
|
||||
*
|
||||
* 迁移自旧的模块单例 + window 事件方案:
|
||||
* - 组件通过 usePermissionStore 订阅,不再依赖自定义事件;
|
||||
* - 持久化的权限只作为缓存,rehydrate 后 status 仍为 `unknown`,
|
||||
* 必须等待 /auth/profile 校验成功后才视为可用(保持 fail-closed)。
|
||||
*/
|
||||
export const usePermissionStore = create<PermissionStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
permissions: [],
|
||||
status: 'unknown',
|
||||
beginPermissionVerification: () => {
|
||||
// 后台刷新时保留已就绪的权限,避免界面闪加载
|
||||
if (get().status === 'ready') return;
|
||||
set({ permissions: [], status: 'loading' }, false, 'permission/beginVerification');
|
||||
},
|
||||
writePermissions: (permissions) => {
|
||||
set(
|
||||
{ permissions: [...new Set(permissions)], status: 'ready' },
|
||||
false,
|
||||
'permission/write',
|
||||
);
|
||||
},
|
||||
clearPermissions: (status = 'unknown') => {
|
||||
set({ permissions: [], status }, false, 'permission/clear');
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: PERMISSION_STORAGE_NAME,
|
||||
storage: permissionPersistStorage,
|
||||
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
{ name: 'permission-store', enabled: import.meta.env.DEV },
|
||||
),
|
||||
);
|
||||
25
apps/admin/src/store/permission/permissionTypes.ts
Normal file
25
apps/admin/src/store/permission/permissionTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { StoreStatus } from '../types';
|
||||
|
||||
export type PermissionStatus = StoreStatus;
|
||||
|
||||
export interface PermissionState {
|
||||
/** 已校验通过的权限码 */
|
||||
permissions: string[];
|
||||
status: PermissionStatus;
|
||||
}
|
||||
|
||||
export interface PermissionActions {
|
||||
/** 开始后台校验:非 ready 状态时清空权限并进入 loading(fail-closed) */
|
||||
beginPermissionVerification: () => void;
|
||||
/** 写入已校验的权限 */
|
||||
writePermissions: (permissions: string[]) => void;
|
||||
/** 清空权限(退出登录 / 401) */
|
||||
clearPermissions: (status?: PermissionStatus) => void;
|
||||
}
|
||||
|
||||
export type PermissionStore = PermissionState & PermissionActions;
|
||||
|
||||
/** 持久化子集:只保存权限码,状态恢复后一律为 unknown */
|
||||
export interface PermissionPersistedState {
|
||||
permissions: string[];
|
||||
}
|
||||
45
apps/admin/src/store/settings/settingsStore.ts
Normal file
45
apps/admin/src/store/settings/settingsStore.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createJSONStorage } from 'zustand/middleware';
|
||||
import { SETTINGS_STORAGE_NAME } from '../middleware/persist';
|
||||
import type { SettingsState, SettingsStore } from './settingsTypes';
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
aiChat: {
|
||||
deepThinking: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户偏好 Store:AI 聊天等非业务、可持久化的设置。
|
||||
*/
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
...defaultSettings,
|
||||
setAiChatDeepThinking: (enabled) => {
|
||||
set(
|
||||
(state) => ({ aiChat: { ...state.aiChat, deepThinking: enabled } }),
|
||||
false,
|
||||
'settings/setAiChatDeepThinking',
|
||||
);
|
||||
},
|
||||
toggleAiChatDeepThinking: () => {
|
||||
set(
|
||||
(state) => ({ aiChat: { ...state.aiChat, deepThinking: !state.aiChat.deepThinking } }),
|
||||
false,
|
||||
'settings/toggleAiChatDeepThinking',
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: SETTINGS_STORAGE_NAME,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state): SettingsState => ({ aiChat: state.aiChat }),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
{ name: 'settings-store', enabled: import.meta.env.DEV },
|
||||
),
|
||||
);
|
||||
15
apps/admin/src/store/settings/settingsTypes.ts
Normal file
15
apps/admin/src/store/settings/settingsTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface AiChatSettings {
|
||||
/** 深度思考(reasoningEffort: high)偏好 */
|
||||
deepThinking: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
aiChat: AiChatSettings;
|
||||
}
|
||||
|
||||
export interface SettingsActions {
|
||||
setAiChatDeepThinking: (enabled: boolean) => void;
|
||||
toggleAiChatDeepThinking: () => void;
|
||||
}
|
||||
|
||||
export type SettingsStore = SettingsState & SettingsActions;
|
||||
14
apps/admin/src/store/types.ts
Normal file
14
apps/admin/src/store/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 全局状态管理通用类型。
|
||||
*
|
||||
* 各领域 Store 采用“模块化切片”设计:
|
||||
* - 一个领域一个目录,state/actions 分别定义;
|
||||
* - 所有 Store 由 zustand `create` + `devtools` + `persist` 组合创建;
|
||||
* - 组件只通过 `src/store/index.ts` 或具体 Store 文件访问,保持单向数据流。
|
||||
*/
|
||||
|
||||
/** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */
|
||||
export type StoreStatus = 'unknown' | 'loading' | 'ready';
|
||||
|
||||
/** 持久化时从 Store 中挑选出的字段 */
|
||||
export type Partialize<T> = (state: T) => Partial<T>;
|
||||
33
apps/admin/src/store/user/userActions.ts
Normal file
33
apps/admin/src/store/user/userActions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { UserActions, UserStore } from './userTypes';
|
||||
|
||||
/** user Store 应用到的中间件,用于让 createUserActions 获得完整 set 类型 */
|
||||
export type UserStoreMutators = [
|
||||
['zustand/devtools', never],
|
||||
['zustand/persist', unknown],
|
||||
];
|
||||
|
||||
/**
|
||||
* 用户会话 actions,与 state 分离,避免 Store 无限膨胀。
|
||||
* 由 userStore.ts 组合进 create()。
|
||||
*/
|
||||
export const createUserActions: StateCreator<UserStore, UserStoreMutators, [], UserActions> = (
|
||||
set,
|
||||
) => ({
|
||||
setSession: (token, user) => {
|
||||
set({ token, user }, false, 'user/setSession');
|
||||
},
|
||||
setUser: (user) => {
|
||||
set({ user }, false, 'user/setUser');
|
||||
},
|
||||
updateUser: (patch) => {
|
||||
set(
|
||||
(state) => (state.user ? { user: { ...state.user, ...patch } } : {}),
|
||||
false,
|
||||
'user/updateUser',
|
||||
);
|
||||
},
|
||||
logout: () => {
|
||||
set({ token: null, user: null }, false, 'user/logout');
|
||||
},
|
||||
});
|
||||
29
apps/admin/src/store/user/userStore.ts
Normal file
29
apps/admin/src/store/user/userStore.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { authPersistStorage, AUTH_STORAGE_NAME } from '../middleware/persist';
|
||||
import { createUserActions } from './userActions';
|
||||
import type { UserPersistedState, UserStore } from './userTypes';
|
||||
|
||||
/**
|
||||
* 用户会话 Store(token + 用户资料)。
|
||||
* 使用 zustand 官方推荐写法:create()(devtools(persist(...)))。
|
||||
* 持久化沿用旧 `token` / `user` localStorage key。
|
||||
*/
|
||||
export const useUserStore = create<UserStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get, api) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
...createUserActions(set, get, api),
|
||||
}),
|
||||
{
|
||||
name: AUTH_STORAGE_NAME,
|
||||
storage: authPersistStorage,
|
||||
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
{ name: 'user-store', enabled: import.meta.env.DEV },
|
||||
),
|
||||
);
|
||||
38
apps/admin/src/store/user/userTypes.ts
Normal file
38
apps/admin/src/store/user/userTypes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { StoreStatus } from '../types';
|
||||
|
||||
/** 登录用户信息(与后端 /auth/login、/auth/profile 返回结构对齐) */
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
token: string | null;
|
||||
user: UserInfo | null;
|
||||
}
|
||||
|
||||
export interface UserActions {
|
||||
/** 登录成功后写入完整会话 */
|
||||
setSession: (token: string, user: UserInfo) => void;
|
||||
/** 整体替换用户信息 */
|
||||
setUser: (user: UserInfo | null) => void;
|
||||
/** 局部更新用户信息(如 profile 校验返回的最新数据) */
|
||||
updateUser: (patch: Partial<UserInfo>) => void;
|
||||
/** 退出登录:清空会话 */
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
/** 持久化子集 */
|
||||
export interface UserPersistedState {
|
||||
token: string | null;
|
||||
user: UserInfo | null;
|
||||
}
|
||||
|
||||
/** 兼容:用户状态中的权限由 permission Store 统一管理 */
|
||||
export type { StoreStatus };
|
||||
@@ -8,6 +8,9 @@
|
||||
import { expect } from 'vitest';
|
||||
import { CREDENTIALS } from './fixtures';
|
||||
import { BASE } from './setup';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
import type { UserInfo } from '../store/user/userTypes';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,8 +40,10 @@ export async function loginAs(
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
|
||||
expect(json.code).toBe(0);
|
||||
localStorage.setItem('token', json.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(json.data.user));
|
||||
useUserStore.getState().setSession(json.data.token, json.data.user as unknown as UserInfo);
|
||||
usePermissionStore
|
||||
.getState()
|
||||
.writePermissions((json.data.user.permissions ?? []) as string[]);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
@@ -46,15 +51,14 @@ export async function loginAs(
|
||||
* Logout: clear localStorage.
|
||||
*/
|
||||
export function logout(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
}
|
||||
|
||||
// ── API helpers (authenticated) ─────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* Runs once before all tests.
|
||||
*/
|
||||
import { beforeAll, afterEach } from 'vitest';
|
||||
import { useAppStore } from '../store/app/appStore';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useSettingsStore } from '../store/settings/settingsStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
// Base URL: the Vite dev server proxies /api → localhost:3003
|
||||
const BASE = 'http://localhost:3002';
|
||||
@@ -18,6 +22,17 @@ afterEach(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
useAppStore.setState({
|
||||
sidebarCollapsed: false,
|
||||
mobileDrawerOpen: false,
|
||||
menuOpenKeys: [],
|
||||
aiChatOpen: false,
|
||||
aiWorking: false,
|
||||
routeDockTabs: [],
|
||||
});
|
||||
useSettingsStore.setState({ aiChat: { deepThinking: false } });
|
||||
});
|
||||
|
||||
export { BASE };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
/**
|
||||
* Download a file from the API as a blob and trigger a browser download.
|
||||
*
|
||||
@@ -9,7 +11,7 @@ export async function downloadBlob(endpoint: string, filename: string): Promise<
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
const res = await fetch(`${baseURL}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import { playwright } from '@vitest/browser-playwright';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
include: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -31,6 +31,18 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
|
||||
description: '查询账单编号、账期、金额和状态。',
|
||||
examples: ['查找本月未支付账单', '查询张同学最近的账单'],
|
||||
},
|
||||
{
|
||||
key: 'classroom',
|
||||
name: '教室与租用',
|
||||
description: '查询教室信息、占用状态和租赁订单。',
|
||||
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'],
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
name: '同步状态',
|
||||
description: '查询钉钉、企业微信等平台最近一次同步状态和排课映射进度。',
|
||||
examples: ['最近一次钉钉同步是什么时候?', '同步状态正常吗?'],
|
||||
},
|
||||
];
|
||||
|
||||
export const AGENT_SKILL_KEYS = new Set(AGENT_SKILLS.map((skill) => skill.key));
|
||||
|
||||
@@ -16,6 +16,22 @@ import { SearchRoomsTool } from './tools/search-rooms.tool';
|
||||
import { GetRoomOccupancySummaryTool } from './tools/get-room-occupancy-summary.tool';
|
||||
import { SearchBillsTool } from './tools/search-bills.tool';
|
||||
import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool';
|
||||
import { CreateStudentTool } from './tools/create-student.tool';
|
||||
import { UpdateStudentsTool } from './tools/update-students.tool';
|
||||
import { SearchExamsTool } from './tools/search-exams.tool';
|
||||
import { SearchSchedulesTool } from './tools/search-schedules.tool';
|
||||
import { SearchDepositsTool } from './tools/search-deposits.tool';
|
||||
import { SearchExpensesTool } from './tools/search-expenses.tool';
|
||||
import { SearchClassroomsTool } from './tools/search-classrooms.tool';
|
||||
import { SearchClassroomRentalsTool } from './tools/search-classroom-rentals.tool';
|
||||
import { GetSyncStatusTool } from './tools/get-sync-status.tool';
|
||||
import { ExamsModule } from '../exams/exams.module';
|
||||
import { SchedulesModule } from '../schedules/schedules.module';
|
||||
import { DepositsModule } from '../deposits/deposits.module';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Agent Tools feature module.
|
||||
@@ -32,7 +48,21 @@ import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool';
|
||||
* globally available `AuthorizationModule` and `OperationLogsModule`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [StudentsModule, ClassesModule, AttendanceModule, RoomsModule, BillsModule, DashboardModule],
|
||||
imports: [
|
||||
StudentsModule,
|
||||
ClassesModule,
|
||||
AttendanceModule,
|
||||
RoomsModule,
|
||||
BillsModule,
|
||||
DashboardModule,
|
||||
ExamsModule,
|
||||
SchedulesModule,
|
||||
DepositsModule,
|
||||
ExpensesModule,
|
||||
ClassroomsModule,
|
||||
ClassroomRentalsModule,
|
||||
SyncModule,
|
||||
],
|
||||
providers: [
|
||||
AgentToolRegistry,
|
||||
AgentToolExecutor,
|
||||
@@ -45,6 +75,15 @@ import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool';
|
||||
GetRoomOccupancySummaryTool,
|
||||
SearchBillsTool,
|
||||
GetDashboardStatsTool,
|
||||
CreateStudentTool,
|
||||
UpdateStudentsTool,
|
||||
SearchExamsTool,
|
||||
SearchSchedulesTool,
|
||||
SearchDepositsTool,
|
||||
SearchExpensesTool,
|
||||
SearchClassroomsTool,
|
||||
SearchClassroomRentalsTool,
|
||||
GetSyncStatusTool,
|
||||
],
|
||||
exports: [AgentToolExecutor],
|
||||
})
|
||||
@@ -59,6 +98,15 @@ export class AgentToolsModule implements OnModuleInit {
|
||||
private readonly roomOccupancyTool: GetRoomOccupancySummaryTool,
|
||||
private readonly searchBillsTool: SearchBillsTool,
|
||||
private readonly dashboardStatsTool: GetDashboardStatsTool,
|
||||
private readonly createStudentTool: CreateStudentTool,
|
||||
private readonly updateStudentsTool: UpdateStudentsTool,
|
||||
private readonly searchExamsTool: SearchExamsTool,
|
||||
private readonly searchSchedulesTool: SearchSchedulesTool,
|
||||
private readonly searchDepositsTool: SearchDepositsTool,
|
||||
private readonly searchExpensesTool: SearchExpensesTool,
|
||||
private readonly searchClassroomsTool: SearchClassroomsTool,
|
||||
private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool,
|
||||
private readonly getSyncStatusTool: GetSyncStatusTool,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -70,5 +118,14 @@ export class AgentToolsModule implements OnModuleInit {
|
||||
this.registry.register(this.roomOccupancyTool);
|
||||
this.registry.register(this.searchBillsTool);
|
||||
this.registry.register(this.dashboardStatsTool);
|
||||
this.registry.register(this.createStudentTool);
|
||||
this.registry.register(this.updateStudentsTool);
|
||||
this.registry.register(this.searchExamsTool);
|
||||
this.registry.register(this.searchSchedulesTool);
|
||||
this.registry.register(this.searchDepositsTool);
|
||||
this.registry.register(this.searchExpensesTool);
|
||||
this.registry.register(this.searchClassroomsTool);
|
||||
this.registry.register(this.searchClassroomRentalsTool);
|
||||
this.registry.register(this.getSyncStatusTool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import { SearchRoomsTool } from './search-rooms.tool';
|
||||
import { GetRoomOccupancySummaryTool } from './get-room-occupancy-summary.tool';
|
||||
import { SearchBillsTool } from './search-bills.tool';
|
||||
import { GetDashboardStatsTool } from './get-dashboard-stats.tool';
|
||||
import { SearchExamsTool } from './search-exams.tool';
|
||||
import { SearchSchedulesTool } from './search-schedules.tool';
|
||||
import { SearchDepositsTool } from './search-deposits.tool';
|
||||
import { SearchExpensesTool } from './search-expenses.tool';
|
||||
import { SearchClassroomsTool } from './search-classrooms.tool';
|
||||
import { SearchClassroomRentalsTool } from './search-classroom-rentals.tool';
|
||||
import { GetSyncStatusTool } from './get-sync-status.tool';
|
||||
|
||||
function context(permissions: string[] = [], isSuperAdmin = false) {
|
||||
const user: AuthenticatedUser = { id: 7, username: 'teacher', permissions, isSuperAdmin, roles: [] };
|
||||
@@ -76,4 +83,70 @@ describe('agent business tools', () => {
|
||||
expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(1, 7, false);
|
||||
expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(2, 7, true);
|
||||
});
|
||||
|
||||
it('exam tool enforces scope and rejects unknown fields', async () => {
|
||||
const service = { agentSearchExams: jest.fn().mockResolvedValue([]) };
|
||||
const tool = new SearchExamsTool(service as never, scopes);
|
||||
expect(tool.requiredPermission).toBe('exam:view');
|
||||
expect(tool.validate({ userId: 1 }).ok).toBe(false);
|
||||
expect(tool.validate({ limit: 51 }).ok).toBe(false);
|
||||
await tool.execute({ classId: 3, limit: 10 }, context(['exam:view']));
|
||||
expect(service.agentSearchExams).toHaveBeenCalledWith(7, false, { classId: 3, limit: 10 });
|
||||
await tool.execute({}, context([], true));
|
||||
expect(service.agentSearchExams).toHaveBeenLastCalledWith(7, true, {});
|
||||
});
|
||||
|
||||
it('schedule tool validates weekDay range and forwards scope', async () => {
|
||||
const service = { agentSearchSchedules: jest.fn().mockResolvedValue([]) };
|
||||
const tool = new SearchSchedulesTool(service as never, scopes);
|
||||
expect(tool.requiredPermission).toBe('schedule:view');
|
||||
expect(tool.validate({ weekDay: 8 }).ok).toBe(false);
|
||||
expect(tool.validate({ weekDay: 0 }).ok).toBe(false);
|
||||
await tool.execute({ classroomId: 2, weekDay: 3 }, context(['schedule:view']));
|
||||
expect(service.agentSearchSchedules).toHaveBeenCalledWith(7, false, {
|
||||
classroomId: 2,
|
||||
weekDay: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('deposit tool validates and forwards safe input', async () => {
|
||||
const service = { agentSearchDeposits: jest.fn().mockResolvedValue([]) };
|
||||
const tool = new SearchDepositsTool(service as never);
|
||||
expect(tool.requiredPermission).toBe('deposit:view');
|
||||
expect(tool.validate({ permissions: ['deposit:view'] }).ok).toBe(false);
|
||||
await tool.execute({ keyword: '张三', status: 'paid', limit: 10 }, context(['deposit:view']));
|
||||
expect(service.agentSearchDeposits).toHaveBeenCalledWith({
|
||||
keyword: '张三',
|
||||
status: 'paid',
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('expense tool validates period range', async () => {
|
||||
const tool = new SearchExpensesTool({} as never);
|
||||
expect(tool.validate({ periodStart: '2026-08-01', periodEnd: '2026-07-01' }).ok).toBe(false);
|
||||
expect(tool.validate({ periodStart: '2026-02-30' }).ok).toBe(false);
|
||||
expect(tool.validate({ limit: 31 }).ok).toBe(false);
|
||||
expect(tool.validate({ keyword: '3-301' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('classroom and rental tools validate inputs', async () => {
|
||||
const classroomTool = new SearchClassroomsTool({} as never);
|
||||
const rentalTool = new SearchClassroomRentalsTool({} as never);
|
||||
expect(classroomTool.requiredPermission).toBe('classroom:view');
|
||||
expect(classroomTool.validate({ building: '1号楼' }).ok).toBe(true);
|
||||
expect(rentalTool.requiredPermission).toBe('rental:view');
|
||||
expect(rentalTool.validate({ month: '2026-13' }).ok).toBe(false);
|
||||
expect(rentalTool.validate({ month: '2026-08', includeEnded: 'yes' }).ok).toBe(false);
|
||||
expect(rentalTool.validate({ month: '2026-08', includeEnded: true }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('sync status tool rejects any input and forwards nothing', async () => {
|
||||
const service = { agentGetSyncStatus: jest.fn().mockResolvedValue({}) };
|
||||
const tool = new GetSyncStatusTool(service as never);
|
||||
expect(tool.requiredPermission).toBe('sync:read');
|
||||
expect(tool.validate({ debug: true }).ok).toBe(false);
|
||||
await tool.execute({}, context(['sync:read']));
|
||||
expect(service.agentGetSyncStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CreateStudentTool } from './create-student.tool';
|
||||
|
||||
function createTool(overrides: Record<string, unknown> = {}) {
|
||||
const studentsService = {
|
||||
create: jest.fn(async (dto: Record<string, unknown>) => ({ id: 9, ...dto })),
|
||||
...(overrides.studentsService ?? {}),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, status: 'active', isHost: true }),
|
||||
}),
|
||||
...(overrides.dataSource ?? {}),
|
||||
};
|
||||
const tool = new CreateStudentTool(studentsService as never, dataSource as never);
|
||||
return { tool, studentsService, dataSource };
|
||||
}
|
||||
|
||||
describe('CreateStudentTool', () => {
|
||||
it('exposes student:create permission and student skill', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.name).toBe('create_student');
|
||||
expect(tool.skillKey).toBe('student');
|
||||
expect(tool.requiredPermission).toBe('student:create');
|
||||
});
|
||||
|
||||
it('rejects forbidden and unknown fields', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.validate({ userId: 1 }).ok).toBe(false);
|
||||
expect(tool.validate({ permissions: ['student:create'] }).ok).toBe(false);
|
||||
expect(tool.validate({ admin: true }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a valid name and phone', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.validate({}).ok).toBe(false);
|
||||
expect(tool.validate({ name: '' }).ok).toBe(false);
|
||||
expect(tool.validate({ name: '张三', phone: '123' }).ok).toBe(false);
|
||||
expect(tool.validate({ name: '张三', phone: '13800138000', gender: 'other' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('creates student under the default host organization', async () => {
|
||||
const { tool, studentsService } = createTool();
|
||||
const result = await tool.execute(
|
||||
{ name: '张三', phone: '13800138000', gender: 'male', studentNo: 'T001' },
|
||||
{} as never,
|
||||
);
|
||||
expect(studentsService.create).toHaveBeenCalledWith({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
gender: 'male',
|
||||
studentNo: 'T001',
|
||||
idNumber: undefined,
|
||||
organizationId: 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 9,
|
||||
name: '张三',
|
||||
studentNo: 'T001',
|
||||
message: '学生已创建',
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('13800138000');
|
||||
});
|
||||
|
||||
it('uses explicit organizationId when provided', async () => {
|
||||
const { tool, studentsService } = createTool();
|
||||
await tool.execute({ name: '李四', organizationId: 3 }, {} as never);
|
||||
expect(studentsService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ organizationId: 3 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
166
apps/server/src/agent-tools/tools/create-student.tool.ts
Normal file
166
apps/server/src/agent-tools/tools/create-student.tool.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Organization } from '../../entities/organization.entity';
|
||||
import { StudentsService } from '../../students/students.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
|
||||
/** Whitelisted input shape for create_student. */
|
||||
interface CreateStudentInput {
|
||||
name: string;
|
||||
phone?: string;
|
||||
gender?: 'male' | 'female' | '男' | '女';
|
||||
studentNo?: string;
|
||||
idNumber?: string;
|
||||
organizationId?: number;
|
||||
}
|
||||
|
||||
/** Forbidden input keys — if the model sends these, validation fails. */
|
||||
const FORBIDDEN_INPUT_KEYS = new Set([
|
||||
'userId',
|
||||
'isSuperAdmin',
|
||||
'permissions',
|
||||
'roles',
|
||||
'ability',
|
||||
'user',
|
||||
'password',
|
||||
'token',
|
||||
]);
|
||||
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
|
||||
/**
|
||||
* Creates a student archive from form-confirmed data.
|
||||
*
|
||||
* This is a write tool. It is only exposed to the model after the user
|
||||
* submits a rendered form (see AiChatService), and the executor still
|
||||
* enforces `student:create` at execution time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateStudentTool implements ToolDef<CreateStudentInput> {
|
||||
readonly name = 'create_student';
|
||||
readonly skillKey = 'student';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '学生姓名', maxLength: 50 },
|
||||
phone: { type: 'string', description: '11 位手机号', pattern: '^1[3-9]\\d{9}$' },
|
||||
gender: {
|
||||
type: 'string',
|
||||
description: '性别',
|
||||
enum: ['male', 'female', '男', '女'],
|
||||
},
|
||||
studentNo: { type: 'string', description: '学号(选填)', maxLength: 30 },
|
||||
idNumber: { type: 'string', description: '身份证号(选填)', maxLength: 30 },
|
||||
organizationId: { type: 'integer', description: '校区ID(选填,缺省用主校区)', minimum: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
readonly description =
|
||||
'根据用户通过表单提交的学生信息创建学生档案。仅可在表单提交后的轮次使用,不得自行编造或修改字段。';
|
||||
readonly requiredPermission = 'student:create';
|
||||
|
||||
constructor(
|
||||
private readonly studentsService: StudentsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
validate(input: Record<string, unknown>): ToolInputResult<CreateStudentInput> {
|
||||
for (const key of Object.keys(input)) {
|
||||
if (FORBIDDEN_INPUT_KEYS.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = new Set([
|
||||
'name',
|
||||
'phone',
|
||||
'gender',
|
||||
'studentNo',
|
||||
'idNumber',
|
||||
'organizationId',
|
||||
]);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 50) {
|
||||
return { ok: false, error: 'name 必须是 1-50 个字符的字符串' };
|
||||
}
|
||||
|
||||
const result: CreateStudentInput = { name: input.name.trim() };
|
||||
|
||||
if (input.phone !== undefined) {
|
||||
if (typeof input.phone !== 'string' || !PHONE_RE.test(input.phone)) {
|
||||
return { ok: false, error: 'phone 必须是 11 位手机号' };
|
||||
}
|
||||
result.phone = input.phone;
|
||||
}
|
||||
|
||||
if (input.gender !== undefined) {
|
||||
if (!['male', 'female', '男', '女'].includes(String(input.gender))) {
|
||||
return { ok: false, error: 'gender 只能是 male/female/男/女' };
|
||||
}
|
||||
result.gender = input.gender as CreateStudentInput['gender'];
|
||||
}
|
||||
|
||||
if (input.studentNo !== undefined) {
|
||||
if (typeof input.studentNo !== 'string' || input.studentNo.length > 30) {
|
||||
return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' };
|
||||
}
|
||||
result.studentNo = input.studentNo;
|
||||
}
|
||||
|
||||
if (input.idNumber !== undefined) {
|
||||
if (typeof input.idNumber !== 'string' || input.idNumber.length > 30) {
|
||||
return { ok: false, error: 'idNumber 必须是长度不超过 30 的字符串' };
|
||||
}
|
||||
result.idNumber = input.idNumber;
|
||||
}
|
||||
|
||||
if (input.organizationId !== undefined) {
|
||||
const id = Number(input.organizationId);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { ok: false, error: 'organizationId 必须是正整数' };
|
||||
}
|
||||
result.organizationId = id;
|
||||
}
|
||||
|
||||
return { ok: true, value: result };
|
||||
}
|
||||
|
||||
async execute(input: CreateStudentInput, _context: AgentToolContext): Promise<unknown> {
|
||||
const organizationId = input.organizationId ?? (await this.resolveDefaultOrganizationId());
|
||||
const created = await this.studentsService.create({
|
||||
name: input.name,
|
||||
phone: input.phone,
|
||||
gender: input.gender,
|
||||
studentNo: input.studentNo,
|
||||
idNumber: input.idNumber,
|
||||
organizationId,
|
||||
});
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
studentNo: created.studentNo ?? null,
|
||||
message: '学生已创建',
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDefaultOrganizationId(): Promise<number> {
|
||||
const repo = this.dataSource.getRepository(Organization);
|
||||
const host = await repo.findOne({
|
||||
where: { isHost: true, status: 'active' },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const organization =
|
||||
host ??
|
||||
(await repo.findOne({
|
||||
where: { status: 'active' },
|
||||
order: { id: 'ASC' },
|
||||
}));
|
||||
if (!organization) throw new Error('未找到可用校区,无法创建学生');
|
||||
return organization.id;
|
||||
}
|
||||
}
|
||||
23
apps/server/src/agent-tools/tools/get-sync-status.tool.ts
Normal file
23
apps/server/src/agent-tools/tools/get-sync-status.tool.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SyncService } from '../../sync/sync.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { rejectUnknownKeys } from './tool-input';
|
||||
|
||||
@Injectable()
|
||||
export class GetSyncStatusTool implements ToolDef<Record<string, never>> {
|
||||
readonly name = 'get_sync_status';
|
||||
readonly skillKey = 'sync';
|
||||
readonly description = '查询钉钉学生/考勤、企业微信等平台最近一次同步状态,以及排课映射进度。';
|
||||
readonly requiredPermission = 'sync:read';
|
||||
readonly inputSchema = { type: 'object', properties: {}, additionalProperties: false };
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Record<string, never>> {
|
||||
const invalid = rejectUnknownKeys(raw, []);
|
||||
return invalid ?? { ok: true, value: {} };
|
||||
}
|
||||
|
||||
execute(_input: Record<string, never>, _context: AgentToolContext) {
|
||||
return this.service.agentGetSyncStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClassroomRentalsService } from '../../classroom-rentals/classroom-rentals.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalPositiveInt, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { classroomId?: number; month?: string; includeEnded?: boolean; limit?: number }
|
||||
|
||||
function optionalMonth(value: unknown): ToolInputResult<string | undefined> {
|
||||
if (value === undefined) return { ok: true, value: undefined };
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}$/.test(value)) {
|
||||
return { ok: false, error: 'month 必须是 YYYY-MM 格式' };
|
||||
}
|
||||
const [year, month] = value.split('-').map(Number);
|
||||
if (month < 1 || month > 12 || year < 2000 || year > 2100) {
|
||||
return { ok: false, error: 'month 不是有效月份' };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchClassroomRentalsTool implements ToolDef<Input> {
|
||||
readonly name = 'search_classroom_rentals';
|
||||
readonly skillKey = 'classroom';
|
||||
readonly description = '查询教室租赁订单(教室、承租方机构、起止日期、租金、状态)。';
|
||||
readonly requiredPermission = 'rental:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
classroomId: { type: 'integer', minimum: 1 },
|
||||
month: { type: 'string', description: 'YYYY-MM' },
|
||||
includeEnded: { type: 'boolean', description: '是否包含已结束订单' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(private readonly service: ClassroomRentalsService) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['classroomId', 'month', 'includeEnded', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const classroomId = optionalPositiveInt(raw.classroomId, 'classroomId');
|
||||
if (!classroomId.ok) return classroomId;
|
||||
const month = optionalMonth(raw.month); if (!month.ok) return month;
|
||||
if (raw.includeEnded !== undefined && typeof raw.includeEnded !== 'boolean') {
|
||||
return { ok: false, error: 'includeEnded 必须是布尔值' };
|
||||
}
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
classroomId: classroomId.value,
|
||||
month: month.value,
|
||||
includeEnded: raw.includeEnded === undefined ? undefined : Boolean(raw.includeEnded),
|
||||
limit: limit.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
execute(input: Input, _context: AgentToolContext) {
|
||||
return this.service.agentSearchRentals(input);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/agent-tools/tools/search-classrooms.tool.ts
Normal file
37
apps/server/src/agent-tools/tools/search-classrooms.tool.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClassroomsService } from '../../classrooms/classrooms.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { keyword?: string; building?: string; limit?: number }
|
||||
|
||||
@Injectable()
|
||||
export class SearchClassroomsTool implements ToolDef<Input> {
|
||||
readonly name = 'search_classrooms';
|
||||
readonly skillKey = 'classroom';
|
||||
readonly description = '查询教室(名称、楼栋、容量、房型、当前占用状态),不返回排课明细。';
|
||||
readonly requiredPermission = 'classroom:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: { type: 'string', maxLength: 100, description: '教室名称关键词' },
|
||||
building: { type: 'string', maxLength: 50, description: '楼栋' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(private readonly service: ClassroomsService) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['keyword', 'building', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
|
||||
const building = optionalString(raw.building, 'building', 50); if (!building.ok) return building;
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
|
||||
return { ok: true, value: { keyword: keyword.value, building: building.value, limit: limit.value } };
|
||||
}
|
||||
|
||||
execute(input: Input, _context: AgentToolContext) {
|
||||
return this.service.agentSearchClassrooms(input);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/agent-tools/tools/search-deposits.tool.ts
Normal file
37
apps/server/src/agent-tools/tools/search-deposits.tool.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DepositsService } from '../../deposits/deposits.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { keyword?: string; status?: string; limit?: number }
|
||||
|
||||
@Injectable()
|
||||
export class SearchDepositsTool implements ToolDef<Input> {
|
||||
readonly name = 'search_deposits';
|
||||
readonly skillKey = 'billing';
|
||||
readonly description = '查询押金记录(学生姓名/学号、金额、状态、退款信息)。';
|
||||
readonly requiredPermission = 'deposit:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: { type: 'string', maxLength: 100, description: '学生姓名或学号' },
|
||||
status: { type: 'string', maxLength: 20, description: '押金状态' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(private readonly service: DepositsService) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['keyword', 'status', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
|
||||
const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
|
||||
return { ok: true, value: { keyword: keyword.value, status: status.value, limit: limit.value } };
|
||||
}
|
||||
|
||||
execute(input: Input, _context: AgentToolContext) {
|
||||
return this.service.agentSearchDeposits(input);
|
||||
}
|
||||
}
|
||||
50
apps/server/src/agent-tools/tools/search-exams.tool.ts
Normal file
50
apps/server/src/agent-tools/tools/search-exams.tool.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExamsService } from '../../exams/exams.service';
|
||||
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { keyword?: string; examType?: string; classId?: number; limit?: number }
|
||||
|
||||
@Injectable()
|
||||
export class SearchExamsTool implements ToolDef<Input> {
|
||||
readonly name = 'search_exams';
|
||||
readonly skillKey = 'student';
|
||||
readonly description = '查询当前用户有权查看的考试及成绩录入进度(考试名称、类型、日期、班级、应录/已录人数)。';
|
||||
readonly requiredPermission = 'exam:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: { type: 'string', maxLength: 100, description: '考试名称关键词' },
|
||||
examType: { type: 'string', maxLength: 50, description: '考试类型' },
|
||||
classId: { type: 'integer', minimum: 1, description: '班级ID' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(
|
||||
private readonly service: ExamsService,
|
||||
private readonly scopes: AgentBusinessScopeFactory,
|
||||
) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['keyword', 'examType', 'classId', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
|
||||
const examType = optionalString(raw.examType, 'examType', 50); if (!examType.ok) return examType;
|
||||
const classId = optionalPositiveInt(raw.classId, 'classId'); if (!classId.ok) return classId;
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
|
||||
return {
|
||||
ok: true,
|
||||
value: { keyword: keyword.value, examType: examType.value, classId: classId.value, limit: limit.value },
|
||||
};
|
||||
}
|
||||
|
||||
execute(input: Input, context: AgentToolContext) {
|
||||
return this.service.agentSearchExams(
|
||||
context.userId,
|
||||
this.scopes.canManageAllClasses(context),
|
||||
input,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-tools/tools/search-expenses.tool.ts
Normal file
52
apps/server/src/agent-tools/tools/search-expenses.tool.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpensesService } from '../../expenses/expenses.service';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalDate, optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { keyword?: string; periodStart?: string; periodEnd?: string; limit?: number }
|
||||
|
||||
@Injectable()
|
||||
export class SearchExpensesTool implements ToolDef<Input> {
|
||||
readonly name = 'search_expenses';
|
||||
readonly skillKey = 'billing';
|
||||
readonly description = '查询费用记录(宿舍水电费/杂费和个人附加费),支持按宿舍号、学生姓名/学号和账期筛选。';
|
||||
readonly requiredPermission = 'expense:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: { type: 'string', maxLength: 100, description: '宿舍号或学生姓名/学号' },
|
||||
periodStart: { type: 'string', format: 'date' },
|
||||
periodEnd: { type: 'string', format: 'date' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 30 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(private readonly service: ExpensesService) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['keyword', 'periodStart', 'periodEnd', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
|
||||
const periodStart = optionalDate(raw.periodStart, 'periodStart');
|
||||
if (!periodStart.ok) return periodStart;
|
||||
const periodEnd = optionalDate(raw.periodEnd, 'periodEnd');
|
||||
if (!periodEnd.ok) return periodEnd;
|
||||
if (periodStart.value && periodEnd.value && periodStart.value > periodEnd.value) {
|
||||
return { ok: false, error: 'periodEnd 不能早于 periodStart' };
|
||||
}
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
keyword: keyword.value,
|
||||
periodStart: periodStart.value,
|
||||
periodEnd: periodEnd.value,
|
||||
limit: limit.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
execute(input: Input, _context: AgentToolContext) {
|
||||
return this.service.agentSearchExpenses(input);
|
||||
}
|
||||
}
|
||||
60
apps/server/src/agent-tools/tools/search-schedules.tool.ts
Normal file
60
apps/server/src/agent-tools/tools/search-schedules.tool.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SchedulesService } from '../../schedules/schedules.service';
|
||||
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
import { optionalPositiveInt, rejectUnknownKeys } from './tool-input';
|
||||
|
||||
interface Input { classId?: number; classroomId?: number; weekDay?: number; limit?: number }
|
||||
|
||||
@Injectable()
|
||||
export class SearchSchedulesTool implements ToolDef<Input> {
|
||||
readonly name = 'search_schedules';
|
||||
readonly skillKey = 'student';
|
||||
readonly description = '查询当前用户有权查看的排课(班级、教室、星期、节次、教师、起止日期)。';
|
||||
readonly requiredPermission = 'schedule:view';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
classId: { type: 'integer', minimum: 1, description: '班级ID' },
|
||||
classroomId: { type: 'integer', minimum: 1, description: '教室ID' },
|
||||
weekDay: { type: 'integer', minimum: 1, maximum: 7, description: '星期(1-7)' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(
|
||||
private readonly service: SchedulesService,
|
||||
private readonly scopes: AgentBusinessScopeFactory,
|
||||
) {}
|
||||
|
||||
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
|
||||
const invalid = rejectUnknownKeys(raw, ['classId', 'classroomId', 'weekDay', 'limit']);
|
||||
if (invalid) return invalid;
|
||||
const classId = optionalPositiveInt(raw.classId, 'classId'); if (!classId.ok) return classId;
|
||||
const classroomId = optionalPositiveInt(raw.classroomId, 'classroomId');
|
||||
if (!classroomId.ok) return classroomId;
|
||||
const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay');
|
||||
if (!weekDay.ok) return weekDay;
|
||||
if (weekDay.value !== undefined && weekDay.value > 7) {
|
||||
return { ok: false, error: 'weekDay 必须在 1-7 之间' };
|
||||
}
|
||||
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
classId: classId.value,
|
||||
classroomId: classroomId.value,
|
||||
weekDay: weekDay.value,
|
||||
limit: limit.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
execute(input: Input, context: AgentToolContext) {
|
||||
return this.service.agentSearchSchedules(
|
||||
context.userId,
|
||||
this.scopes.canManageAllClasses(context),
|
||||
input,
|
||||
);
|
||||
}
|
||||
}
|
||||
124
apps/server/src/agent-tools/tools/update-students.tool.spec.ts
Normal file
124
apps/server/src/agent-tools/tools/update-students.tool.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { UpdateStudentsTool } from './update-students.tool';
|
||||
|
||||
function createTool(overrides: Record<string, unknown> = {}) {
|
||||
const studentsService = {
|
||||
update: jest.fn(async (id: number, dto: Record<string, unknown>) => ({
|
||||
id,
|
||||
name: dto.name ?? '学生',
|
||||
})),
|
||||
...(overrides.studentsService ?? {}),
|
||||
};
|
||||
const tool = new UpdateStudentsTool(studentsService as never);
|
||||
return { tool, studentsService };
|
||||
}
|
||||
|
||||
const validInput = {
|
||||
updates: [
|
||||
{ id: 201, name: '於嘉丽' },
|
||||
{ id: 172, name: '徐玚' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('UpdateStudentsTool', () => {
|
||||
it('exposes student:edit permission and student skill', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.name).toBe('update_students');
|
||||
expect(tool.skillKey).toBe('student');
|
||||
expect(tool.requiredPermission).toBe('student:edit');
|
||||
});
|
||||
|
||||
it('rejects forbidden, unknown, and empty input', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.validate({ userId: 1 }).ok).toBe(false);
|
||||
expect(tool.validate({ updates: [], admin: true }).ok).toBe(false);
|
||||
expect(tool.validate({}).ok).toBe(false);
|
||||
expect(tool.validate({ updates: [] }).ok).toBe(false);
|
||||
expect(tool.validate({ updates: [{ id: 201 }] }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid ids, duplicate ids, invalid fields, and oversized batches', () => {
|
||||
const { tool } = createTool();
|
||||
expect(tool.validate({ updates: [{ id: 0, name: 'A' }] }).ok).toBe(false);
|
||||
expect(tool.validate({ updates: [{ id: 'x', name: 'A' }] }).ok).toBe(false);
|
||||
expect(
|
||||
tool.validate({
|
||||
updates: [
|
||||
{ id: 201, name: 'A' },
|
||||
{ id: 201, name: 'B' },
|
||||
],
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(tool.validate({ updates: [{ id: 201, status: 'archived' }] }).ok).toBe(false);
|
||||
expect(tool.validate({ updates: [{ id: 201, name: 'A'.repeat(51) }] }).ok).toBe(false);
|
||||
expect(
|
||||
tool.validate({
|
||||
updates: Array.from({ length: 13 }, (_, index) => ({ id: index + 1, name: 'A' })),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts all supported editable fields', () => {
|
||||
const { tool } = createTool();
|
||||
const result = tool.validate({
|
||||
updates: [
|
||||
{
|
||||
id: 201,
|
||||
name: '於嘉丽',
|
||||
studentNo: 'S201',
|
||||
phone: '13800138000',
|
||||
idNumber: 'ID201',
|
||||
gender: '女',
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: '家长',
|
||||
emergencyPhone: '13900139000',
|
||||
organizationId: 2,
|
||||
supervisor: '王老师',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.updates[0]).toMatchObject({
|
||||
id: 201,
|
||||
name: '於嘉丽',
|
||||
organizationId: 2,
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('updates every student and reports a safe summary', async () => {
|
||||
const { tool, studentsService } = createTool();
|
||||
const result = await tool.execute(validInput, {} as never);
|
||||
expect(studentsService.update).toHaveBeenCalledTimes(2);
|
||||
expect(studentsService.update).toHaveBeenCalledWith(201, { name: '於嘉丽' });
|
||||
expect(result).toEqual({
|
||||
message: '成功更新 2 名学生,失败 0 条',
|
||||
updated: [
|
||||
{ id: 201, name: '於嘉丽' },
|
||||
{ id: 172, name: '徐玚' },
|
||||
],
|
||||
failed: [],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('13800138000');
|
||||
});
|
||||
|
||||
it('continues when one student cannot be updated', async () => {
|
||||
const { tool } = createTool({
|
||||
studentsService: {
|
||||
update: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new NotFoundException('not found'))
|
||||
.mockResolvedValueOnce({ id: 172, name: '徐玚' }),
|
||||
},
|
||||
});
|
||||
const result = await tool.execute(validInput, {} as never);
|
||||
expect(result).toEqual({
|
||||
message: '成功更新 1 名学生,失败 1 条',
|
||||
updated: [{ id: 172, name: '徐玚' }],
|
||||
failed: [{ id: 201, error: '学生不存在' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
245
apps/server/src/agent-tools/tools/update-students.tool.ts
Normal file
245
apps/server/src/agent-tools/tools/update-students.tool.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StudentsService } from '../../students/students.service';
|
||||
import type { UpdateStudentDto } from '../../students/dto/student.dto';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
|
||||
/** Whitelisted editable fields on a single student update. */
|
||||
interface UpdateStudentInput {
|
||||
id: number;
|
||||
name?: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organizationId?: number;
|
||||
supervisor?: string;
|
||||
status?: 'active' | 'graduated' | 'withdrawn';
|
||||
}
|
||||
|
||||
interface UpdateStudentsInput {
|
||||
updates: UpdateStudentInput[];
|
||||
}
|
||||
|
||||
type StringField =
|
||||
| 'name'
|
||||
| 'studentNo'
|
||||
| 'phone'
|
||||
| 'idNumber'
|
||||
| 'gender'
|
||||
| 'ethnicity'
|
||||
| 'emergencyContact'
|
||||
| 'emergencyPhone'
|
||||
| 'supervisor';
|
||||
|
||||
/** Forbidden input keys — if the model sends these, validation fails. */
|
||||
const FORBIDDEN_INPUT_KEYS = new Set([
|
||||
'userId',
|
||||
'isSuperAdmin',
|
||||
'permissions',
|
||||
'roles',
|
||||
'ability',
|
||||
'user',
|
||||
'password',
|
||||
'token',
|
||||
]);
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set(['updates']);
|
||||
const ITEM_KEYS = new Set([
|
||||
'id',
|
||||
'name',
|
||||
'studentNo',
|
||||
'phone',
|
||||
'idNumber',
|
||||
'gender',
|
||||
'ethnicity',
|
||||
'emergencyContact',
|
||||
'emergencyPhone',
|
||||
'organizationId',
|
||||
'supervisor',
|
||||
'status',
|
||||
]);
|
||||
const STATUS_VALUES = new Set(['active', 'graduated', 'withdrawn']);
|
||||
const MAX_BATCH_UPDATES = 12;
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
value: unknown,
|
||||
max: number,
|
||||
): { ok: true; value?: string } | { ok: false; error: string } {
|
||||
if (value === undefined) return { ok: true };
|
||||
if (typeof value !== 'string') return { ok: false, error: '字段必须是字符串' };
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` };
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-updates student profiles from form-confirmed data.
|
||||
*
|
||||
* This is a write tool. It is only exposed to the model after the user
|
||||
* submits a rendered form (see AiChatService), and the executor still
|
||||
* enforces `student:edit` at execution time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UpdateStudentsTool implements ToolDef<UpdateStudentsInput> {
|
||||
readonly name = 'update_students';
|
||||
readonly skillKey = 'student';
|
||||
readonly requiredPermission = 'student:edit';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
updates: {
|
||||
type: 'array',
|
||||
description: '待更新的学生列表(1-12 条,每条必须包含学生 id 和至少一个可编辑字段)',
|
||||
minItems: 1,
|
||||
maxItems: MAX_BATCH_UPDATES,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: '学生 ID', minimum: 1 },
|
||||
name: { type: 'string', description: '学生姓名', maxLength: 50 },
|
||||
studentNo: { type: 'string', description: '学号', maxLength: 30 },
|
||||
phone: { type: 'string', description: '手机号', maxLength: 30 },
|
||||
idNumber: { type: 'string', description: '身份证号', maxLength: 30 },
|
||||
gender: { type: 'string', description: '性别', maxLength: 20 },
|
||||
ethnicity: { type: 'string', description: '民族', maxLength: 50 },
|
||||
emergencyContact: { type: 'string', description: '紧急联系人', maxLength: 50 },
|
||||
emergencyPhone: { type: 'string', description: '紧急联系电话', maxLength: 30 },
|
||||
organizationId: { type: 'integer', description: '校区 ID', minimum: 1 },
|
||||
supervisor: { type: 'string', description: '负责人', maxLength: 50 },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: '学生状态',
|
||||
enum: ['active', 'graduated', 'withdrawn'],
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['updates'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
readonly description =
|
||||
'根据用户通过表单确认的信息批量修改学生档案(姓名、学号、手机号、身份证、性别、民族、紧急联系人、校区、负责人、状态等)。仅可在表单提交后的轮次使用,不得自行编造或修改字段。';
|
||||
|
||||
constructor(private readonly studentsService: StudentsService) {}
|
||||
|
||||
validate(input: Record<string, unknown>): ToolInputResult<UpdateStudentsInput> {
|
||||
for (const key of Object.keys(input)) {
|
||||
if (FORBIDDEN_INPUT_KEYS.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
if (!TOP_LEVEL_KEYS.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.updates) || input.updates.length === 0) {
|
||||
return { ok: false, error: 'updates 至少需要一条记录' };
|
||||
}
|
||||
if (input.updates.length > MAX_BATCH_UPDATES) {
|
||||
return { ok: false, error: `updates 不能超过 ${MAX_BATCH_UPDATES} 条` };
|
||||
}
|
||||
|
||||
const seenIds = new Set<number>();
|
||||
const updates: UpdateStudentInput[] = [];
|
||||
for (let index = 0; index < input.updates.length; index += 1) {
|
||||
const raw = input.updates[index];
|
||||
if (!isPlainRecord(raw)) {
|
||||
return { ok: false, error: `第 ${index + 1} 条更新格式无效` };
|
||||
}
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (FORBIDDEN_INPUT_KEYS.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
if (!ITEM_KEYS.has(key)) {
|
||||
return { ok: false, error: `第 ${index + 1} 条包含未知字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
const id = Number(raw.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { ok: false, error: `第 ${index + 1} 条的学生 id 必须是正整数` };
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
return { ok: false, error: `学生 id 重复: ${id}` };
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
const item: UpdateStudentInput = { id };
|
||||
const stringFields: Array<[StringField, unknown, number]> = [
|
||||
['name', raw.name, 50],
|
||||
['studentNo', raw.studentNo, 30],
|
||||
['phone', raw.phone, 30],
|
||||
['idNumber', raw.idNumber, 30],
|
||||
['gender', raw.gender, 20],
|
||||
['ethnicity', raw.ethnicity, 50],
|
||||
['emergencyContact', raw.emergencyContact, 50],
|
||||
['emergencyPhone', raw.emergencyPhone, 30],
|
||||
['supervisor', raw.supervisor, 50],
|
||||
];
|
||||
for (const [field, value, max] of stringFields) {
|
||||
const parsed = optionalString(value, max);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, error: `第 ${index + 1} 条 ${String(field)}: ${parsed.error}` };
|
||||
}
|
||||
if (parsed.value !== undefined) item[field] = parsed.value;
|
||||
}
|
||||
|
||||
if (raw.organizationId !== undefined) {
|
||||
const organizationId = Number(raw.organizationId);
|
||||
if (!Number.isInteger(organizationId) || organizationId <= 0) {
|
||||
return { ok: false, error: `第 ${index + 1} 条的 organizationId 必须是正整数` };
|
||||
}
|
||||
item.organizationId = organizationId;
|
||||
}
|
||||
|
||||
if (raw.status !== undefined) {
|
||||
if (typeof raw.status !== 'string' || !STATUS_VALUES.has(raw.status)) {
|
||||
return { ok: false, error: `第 ${index + 1} 条的 status 无效` };
|
||||
}
|
||||
item.status = raw.status as UpdateStudentInput['status'];
|
||||
}
|
||||
|
||||
if (Object.keys(item).length === 1) {
|
||||
return { ok: false, error: `第 ${index + 1} 条至少需要一个可编辑字段` };
|
||||
}
|
||||
updates.push(item);
|
||||
}
|
||||
|
||||
return { ok: true, value: { updates } };
|
||||
}
|
||||
|
||||
async execute(input: UpdateStudentsInput, _context: AgentToolContext): Promise<unknown> {
|
||||
const updated: Array<{ id: number; name: string | null }> = [];
|
||||
const failed: Array<{ id: number; error: string }> = [];
|
||||
|
||||
for (const item of input.updates) {
|
||||
const { id: _id, ...rest } = item;
|
||||
const dto = rest as UpdateStudentDto;
|
||||
try {
|
||||
const student = await this.studentsService.update(item.id, dto);
|
||||
updated.push({ id: item.id, name: student?.name ?? null });
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: item.id,
|
||||
error: error instanceof NotFoundException ? '学生不存在' : '更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: `成功更新 ${updated.length} 名学生,失败 ${failed.length} 条`,
|
||||
updated,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import JSZip from 'jszip';
|
||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||
import { AiAttachmentService } from './ai-attachment.service';
|
||||
|
||||
describe('AiAttachmentService', () => {
|
||||
const repository = {
|
||||
findByIds: jest.fn(),
|
||||
};
|
||||
const service = new AiAttachmentService(repository as never);
|
||||
const service = new AiAttachmentService(repository as never, new AiExcelReaderService());
|
||||
|
||||
it.each([
|
||||
[Buffer.from([0xff, 0xd8, 0xff, 0x00]), 'image/jpeg', 'image/jpeg'],
|
||||
@@ -48,6 +50,17 @@ describe('AiAttachmentService', () => {
|
||||
expect(() => assertFileExtension('report.pdf', 'application/pdf')).not.toThrow();
|
||||
});
|
||||
|
||||
it('decodes UTF-8 filenames mangled by Latin-1 multipart parsing', () => {
|
||||
const decodeFilename = (
|
||||
service as unknown as { decodeFilename(name: string): string }
|
||||
).decodeFilename.bind(service);
|
||||
expect(decodeFilename('26æ\u009a\u0091æ\u009c\u009fæ\u0096\u0087å\u008c\u0096课宿è\u0088\u008d.xlsx')).toBe(
|
||||
'26暑期文化课宿舍.xlsx',
|
||||
);
|
||||
expect(decodeFilename('café.xlsx')).toBe('café.xlsx');
|
||||
expect(decodeFilename('暑期.xlsx')).toBe('暑期.xlsx');
|
||||
});
|
||||
|
||||
it('limits the total image bytes sent to a vision model', async () => {
|
||||
await expect(
|
||||
service.toModelParts(
|
||||
@@ -59,4 +72,94 @@ describe('AiAttachmentService', () => {
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('extracts text from namespace-prefixed (WPS-style) xlsx via fallback', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'[Content_Types].xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
</Types>`,
|
||||
);
|
||||
zip.file(
|
||||
'_rels/.rels',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/workbook.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:workbook xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<x:sheets><x:sheet name="名单" sheetId="1" state="visible" r:id="rId1"/></x:sheets>
|
||||
</x:workbook>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/sharedStrings.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:sst xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><x:si><x:t>张三</x:t></x:si></x:sst>`,
|
||||
);
|
||||
zip.file(
|
||||
'xl/worksheets/sheet1.xml',
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<x:worksheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<x:sheetData>
|
||||
<x:row r="1"><x:c r="A1" t="inlineStr"><x:is><x:t>姓名</x:t></x:is></x:c><x:c r="B1" t="inlineStr"><x:is><x:t>手机号</x:t></x:is></x:c></x:row>
|
||||
<x:row r="2"><x:c r="A2" t="s"><x:v>0</x:v></x:c><x:c r="B2"><x:v>13800138000</x:v></x:c></x:row>
|
||||
</x:sheetData>
|
||||
</x:worksheet>`,
|
||||
);
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const extract = (
|
||||
service as unknown as {
|
||||
extractText(buffer: Buffer, mimeType: string): Promise<string | null>;
|
||||
}
|
||||
).extractText.bind(service);
|
||||
const text = await extract(
|
||||
Buffer.from(buffer),
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(text).toContain('# 名单');
|
||||
expect(text).toContain('张三');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import ExcelJS from 'exceljs';
|
||||
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;
|
||||
@@ -23,6 +25,7 @@ const ACCEPTED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
]);
|
||||
|
||||
interface MammothResult {
|
||||
@@ -47,6 +50,8 @@ export class AiAttachmentService {
|
||||
constructor(
|
||||
@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> {
|
||||
@@ -72,7 +77,7 @@ export class AiAttachmentService {
|
||||
entity = await this.attachments.save(
|
||||
this.attachments.create({
|
||||
userId,
|
||||
originalName: basename(file.originalname).slice(0, 255),
|
||||
originalName: this.decodeFilename(basename(file.originalname)).slice(0, 255),
|
||||
mimeType,
|
||||
size: file.size,
|
||||
storageKey,
|
||||
@@ -217,37 +222,83 @@ 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('spreadsheetml')) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||
const lines: string[] = [];
|
||||
workbook.eachSheet((sheet) => {
|
||||
lines.push(`# ${sheet.name}`);
|
||||
sheet.eachRow((row) => {
|
||||
const values = Array.isArray(row.values) ? row.values.slice(1) : [];
|
||||
lines.push(values.map((value) => this.stringifyCellValue(value)).join('\t'));
|
||||
});
|
||||
});
|
||||
return this.normalizeExtractedText(lines.join('\n'));
|
||||
return this.normalizeExtractedText(await this.excelReader.extractText(buffer));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeExtractedText(value: string): string {
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
|
||||
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 stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value) || '';
|
||||
} catch {
|
||||
return '';
|
||||
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.
|
||||
*/
|
||||
async readStoredBuffer(attachment: AiAttachment): Promise<Buffer> {
|
||||
return readFile(this.resolveStoragePath(attachment.storageKey));
|
||||
}
|
||||
|
||||
/** Resolved absolute path of a stored attachment (for OfficeCli). */
|
||||
storagePathFor(attachment: AiAttachment): string {
|
||||
return this.resolveStoragePath(attachment.storageKey);
|
||||
}
|
||||
|
||||
private normalizeExtractedText(value: string): string {
|
||||
return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS);
|
||||
}
|
||||
|
||||
private assertDeclaredType(declared: string, detected: string): void {
|
||||
@@ -264,6 +315,7 @@ export class AiAttachmentService {
|
||||
'application/pdf': ['pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
|
||||
};
|
||||
if (!extension || !expected[mimeType]?.includes(extension)) {
|
||||
throw new BadRequestException('附件扩展名与文件内容不一致');
|
||||
@@ -289,13 +341,32 @@ export class AiAttachmentService {
|
||||
if (
|
||||
isZip &&
|
||||
(declaredMimeType.includes('wordprocessingml') ||
|
||||
declaredMimeType.includes('spreadsheetml'))
|
||||
declaredMimeType.includes('spreadsheetml') ||
|
||||
declaredMimeType.includes('presentationml'))
|
||||
) {
|
||||
return declaredMimeType;
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers send UTF-8 filenames in the multipart header, which multer
|
||||
* decodes as Latin-1 — the stored name then looks like mojibake
|
||||
* (e.g. `26ææ...`). Re-decode when the bytes are valid UTF-8 and
|
||||
* contain CJK; otherwise keep the original name untouched.
|
||||
*/
|
||||
private decodeFilename(name: string): string {
|
||||
if (!/[\u00c0-\u00ff]/.test(name)) return name;
|
||||
try {
|
||||
const decoded = Buffer.from(name, 'latin1').toString('utf8');
|
||||
if (decoded.includes('\uFFFD')) return name;
|
||||
if (!/[\u4e00-\u9fff]/.test(decoded)) return name;
|
||||
return decoded;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private extensionForMime(mimeType: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -304,6 +375,7 @@ export class AiAttachmentService {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
};
|
||||
return extensions[mimeType] || 'bin';
|
||||
}
|
||||
|
||||
86
apps/server/src/ai-chat/ai-chart.service.spec.ts
Normal file
86
apps/server/src/ai-chat/ai-chart.service.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AiChartService } from './ai-chart.service';
|
||||
|
||||
const service = new AiChartService();
|
||||
|
||||
const validSchema = {
|
||||
title: '各班级人数',
|
||||
chartType: 'bar',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'count', title: '人数' },
|
||||
],
|
||||
rows: [
|
||||
{ className: '一班', count: 20 },
|
||||
{ className: '二班', count: 15 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('AiChartService', () => {
|
||||
it('校验通过的图表保留 id、列与行', () => {
|
||||
const chart = service.createChart(validSchema);
|
||||
expect(chart.id).toBeTruthy();
|
||||
expect(chart.chartType).toBe('bar');
|
||||
expect(chart.columns).toEqual(validSchema.columns);
|
||||
expect(chart.rows).toEqual(validSchema.rows);
|
||||
});
|
||||
|
||||
it.each(['line', 'bar', 'pie', 'area', 'radar', 'gauge', 'funnel'])(
|
||||
'支持 %s 图表类型',
|
||||
(chartType) => {
|
||||
const chart = service.createChart({ ...validSchema, chartType });
|
||||
expect(chart.chartType).toBe(chartType);
|
||||
},
|
||||
);
|
||||
|
||||
it('支持散点图并要求至少 3 列', () => {
|
||||
const chart = service.createChart({
|
||||
...validSchema,
|
||||
chartType: 'scatter',
|
||||
columns: [
|
||||
{ key: 'className', title: '班级' },
|
||||
{ key: 'capacity', title: '容量' },
|
||||
{ key: 'occupied', title: '入住人数' },
|
||||
],
|
||||
});
|
||||
expect(chart.chartType).toBe('scatter');
|
||||
expect(() =>
|
||||
service.createChart({ ...validSchema, chartType: 'scatter' }),
|
||||
).toThrow('散点图需要 3 列');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['标题缺失', { chartType: 'bar', columns: validSchema.columns, rows: [] }, '标题'],
|
||||
['类型不支持', { ...validSchema, chartType: 'hack' }, '图表类型不支持'],
|
||||
['列不足', { ...validSchema, columns: [{ key: 'x', title: 'X' }] }, '至少需要 2 列'],
|
||||
['列过多', {
|
||||
...validSchema,
|
||||
columns: Array.from({ length: 11 }, (_, i) => ({ key: `c${i}`, title: `列${i}` })),
|
||||
}, '不能超过 10'],
|
||||
['列名非法', { ...validSchema, columns: [{ key: '类 别', title: 'X' }, { key: 'n', title: 'N' }] }, '只能包含'],
|
||||
['列名重复', {
|
||||
...validSchema,
|
||||
columns: [{ key: 'x', title: 'A' }, { key: 'x', title: 'B' }],
|
||||
}, '列名重复'],
|
||||
['行数超限', {
|
||||
...validSchema,
|
||||
rows: Array.from({ length: 501 }, (_, i) => ({ className: `班${i}`, count: 1 })),
|
||||
}, '不能超过 500'],
|
||||
['单元格类型非法', {
|
||||
...validSchema,
|
||||
rows: [{ className: '一班', count: { hack: true } }],
|
||||
}, '类型不支持'],
|
||||
['未知顶层字段', { ...validSchema, extra: 1 }, '未知属性'],
|
||||
])('非法图表被拒绝:%s', async (_name, schema, messagePart) => {
|
||||
expect(() => service.createChart(schema)).toThrow(BadRequestException);
|
||||
expect(() => service.createChart(schema)).toThrow(messagePart);
|
||||
});
|
||||
|
||||
it('行内未知列被剔除', () => {
|
||||
const chart = service.createChart({
|
||||
...validSchema,
|
||||
rows: [{ className: '一班', count: 20, token: 'secret' }],
|
||||
});
|
||||
expect(chart.rows[0]).toEqual({ className: '一班', count: 20 });
|
||||
});
|
||||
});
|
||||
126
apps/server/src/ai-chat/ai-chart.service.ts
Normal file
126
apps/server/src/ai-chat/ai-chart.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity';
|
||||
|
||||
const MAX_TITLE = 50;
|
||||
const MAX_COLUMNS = 10;
|
||||
const MIN_COLUMNS = 2;
|
||||
const MAX_ROWS = 500;
|
||||
const MAX_CELL_LENGTH = 200;
|
||||
|
||||
const COLUMN_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||||
const CHART_TYPES = new Set(['line', 'bar', 'pie', 'area', 'scatter', 'radar', 'gauge', 'funnel']);
|
||||
const SCHEMA_KEYS = new Set(['title', 'chartType', 'columns', 'rows']);
|
||||
const COLUMN_KEYS_ALLOWED = new Set(['key', 'title']);
|
||||
|
||||
export interface AiChart {
|
||||
id: string;
|
||||
title: string;
|
||||
chartType: 'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'gauge' | 'funnel';
|
||||
columns: AiReviewColumn[];
|
||||
rows: AiReviewRow[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string, max: number): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new BadRequestException(`${label}必须是字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > max) {
|
||||
throw new BadRequestException(`${label}长度不能超过 ${max}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function assertKeys(raw: Record<string, unknown>, allowed: Set<string>, label: string): void {
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) throw new BadRequestException(`${label}包含未知属性: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the `render_chart` tool arguments. The model sends a
|
||||
* whitelisted tabular shape (columns + rows); the frontend converts it
|
||||
* into an ECharts option, so no arbitrary option objects reach the client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiChartService {
|
||||
createChart(rawArgs: unknown): AiChart {
|
||||
if (!isPlainRecord(rawArgs)) throw new BadRequestException('图表参数必须是对象');
|
||||
assertKeys(rawArgs, SCHEMA_KEYS, '图表');
|
||||
const title = requireString(rawArgs.title, '图表标题', MAX_TITLE);
|
||||
if (typeof rawArgs.chartType !== 'string' || !CHART_TYPES.has(rawArgs.chartType)) {
|
||||
throw new BadRequestException('图表类型不支持');
|
||||
}
|
||||
const chartType = rawArgs.chartType as AiChart['chartType'];
|
||||
|
||||
if (!Array.isArray(rawArgs.columns) || rawArgs.columns.length < MIN_COLUMNS) {
|
||||
throw new BadRequestException('图表至少需要 2 列(类别/名称 + 数值)');
|
||||
}
|
||||
if (rawArgs.chartType === 'scatter' && rawArgs.columns.length < 3) {
|
||||
throw new BadRequestException('散点图需要 3 列:名称、X 数值、Y 数值');
|
||||
}
|
||||
if (rawArgs.columns.length > MAX_COLUMNS) {
|
||||
throw new BadRequestException(`图表列数不能超过 ${MAX_COLUMNS}`);
|
||||
}
|
||||
const seenColumns = new Set<string>();
|
||||
const columns = rawArgs.columns.map((column, index) => {
|
||||
if (!isPlainRecord(column)) {
|
||||
throw new BadRequestException(`图表第 ${index + 1} 列格式无效`);
|
||||
}
|
||||
assertKeys(column, COLUMN_KEYS_ALLOWED, `图表第 ${index + 1} 列`);
|
||||
const key = requireString(column.key, `图表第 ${index + 1} 列名`, 50);
|
||||
if (!COLUMN_KEY_RE.test(key)) {
|
||||
throw new BadRequestException(`图表列名 ${key} 只能包含字母、数字、下划线`);
|
||||
}
|
||||
if (seenColumns.has(key)) throw new BadRequestException(`图表列名重复: ${key}`);
|
||||
seenColumns.add(key);
|
||||
const columnTitle = requireString(column.title, `图表列「${key}」标题`, 50);
|
||||
return { key, title: columnTitle };
|
||||
});
|
||||
|
||||
if (!Array.isArray(rawArgs.rows) || rawArgs.rows.length > MAX_ROWS) {
|
||||
throw new BadRequestException(`图表行数不能超过 ${MAX_ROWS}`);
|
||||
}
|
||||
const rows = rawArgs.rows.map((row, index) => this.validateRow(row, index, seenColumns));
|
||||
return { id: uuidV7(), title, chartType, columns, rows };
|
||||
}
|
||||
|
||||
serialize(chart: AiChart): AiChart {
|
||||
return chart;
|
||||
}
|
||||
|
||||
private validateRow(raw: unknown, index: number, knownColumns: Set<string>): AiReviewRow {
|
||||
if (!isPlainRecord(raw)) throw new BadRequestException(`图表第 ${index + 1} 行格式无效`);
|
||||
const row: AiReviewRow = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!knownColumns.has(key)) continue;
|
||||
if (value === null || typeof value === 'boolean') {
|
||||
row[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 必须是有效数字`);
|
||||
}
|
||||
row[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > MAX_CELL_LENGTH) {
|
||||
throw new BadRequestException(
|
||||
`图表第 ${index + 1} 行 ${key} 长度超过 ${MAX_CELL_LENGTH}`,
|
||||
);
|
||||
}
|
||||
row[key] = value;
|
||||
continue;
|
||||
}
|
||||
throw new BadRequestException(`图表第 ${index + 1} 行 ${key} 类型不支持`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,15 @@ 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 {
|
||||
CreateConversationDto,
|
||||
MessageFeedbackDto,
|
||||
MessagePageQueryDto,
|
||||
RegenerateMessageDto,
|
||||
SendMessageDto,
|
||||
SubmitFormDto,
|
||||
SubmitReviewDto,
|
||||
UpdateConversationDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
|
||||
@@ -79,6 +82,14 @@ export class AiChatController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('conversations')
|
||||
async removeAll(@Req() req: AuthenticatedRequest) {
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: await this.service.deleteAllConversations(req.user.id) },
|
||||
};
|
||||
}
|
||||
|
||||
@Post('attachments')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||
async uploadAttachment(
|
||||
@@ -156,6 +167,7 @@ export class AiChatController {
|
||||
id,
|
||||
messageId,
|
||||
dto.clientRequestId,
|
||||
dto.reasoningEffort,
|
||||
signal,
|
||||
emit,
|
||||
onReady,
|
||||
@@ -163,6 +175,66 @@ export class AiChatController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('forms/:formId/submit/stream')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
async submitForm(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('formId') formId: string,
|
||||
@Body() dto: SubmitFormDto,
|
||||
): Promise<void> {
|
||||
const conversationId = await this.service.resolveFormConversationId(req.user.id, formId);
|
||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||
this.service.submitForm(req.user, formId, dto, signal, emit, onReady),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/submit/stream')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
async submitReview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Body() dto: SubmitReviewDto,
|
||||
): Promise<void> {
|
||||
const conversationId = await this.service.resolveReviewConversationId(req.user.id, reviewId);
|
||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
||||
this.service.submitReview(req.user, reviewId, dto, signal, emit, onReady),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
||||
async confirmReviewStep(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Param('sectionKey') sectionKey: string,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.service.confirmReviewStep(
|
||||
req.user,
|
||||
reviewId,
|
||||
sectionKey as AiReviewSection['key'],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('reviews/:reviewId/types/:type/confirm')
|
||||
async confirmReviewGroup(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('reviewId') reviewId: string,
|
||||
@Param('type') type: string,
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.service.confirmReviewGroup(
|
||||
req.user,
|
||||
reviewId,
|
||||
type as AiReviewSectionType,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('messages/:messageId/feedback')
|
||||
async feedback(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@@ -215,6 +287,7 @@ export class AiChatController {
|
||||
}
|
||||
};
|
||||
const onReady = () => {
|
||||
if (res.headersSent) return;
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -247,6 +320,8 @@ export class AiChatController {
|
||||
if (status === 409) return { code: 'CONVERSATION_BUSY', message: '该会话正在生成回答' };
|
||||
if (status === 408) return { code: 'UPSTREAM_TIMEOUT', message: 'AI 服务响应超时' };
|
||||
if (status === 400) return { code: 'BAD_REQUEST', message: error.message };
|
||||
if (status === 429) return { code: 'RATE_LIMITED', message: 'AI 服务请求过于频繁' };
|
||||
if (status >= 500) return { code: 'UPSTREAM_ERROR', message: error.message };
|
||||
}
|
||||
return { code: 'UPSTREAM_ERROR', message: 'AI 服务暂时不可用' };
|
||||
}
|
||||
|
||||
@@ -4,18 +4,46 @@ import { AgentToolsModule } from '../agent-tools';
|
||||
import { AiConfigModule } from '../ai-config/ai-config.module';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
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 { AiReviewService } from './ai-review.service';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiModelStreamService } from './ai-model-stream.service';
|
||||
import { AiAttachment, AiConversation, AiMessage, AiToolRun } from './entities';
|
||||
import { OfficeCliService } from './office-cli.service';
|
||||
import {
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiForm,
|
||||
AiMessage,
|
||||
AiReview,
|
||||
AiToolRun,
|
||||
} from './entities';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiAttachment, AiConversation, AiMessage, AiToolRun]),
|
||||
TypeOrmModule.forFeature([
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiForm,
|
||||
AiMessage,
|
||||
AiReview,
|
||||
AiToolRun,
|
||||
]),
|
||||
AiConfigModule,
|
||||
AgentToolsModule,
|
||||
],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiAttachmentService, AiChatService, AiModelStreamService],
|
||||
providers: [
|
||||
AiAttachmentService,
|
||||
AiChartService,
|
||||
AiExcelReaderService,
|
||||
AiFormService,
|
||||
AiReviewService,
|
||||
OfficeCliService,
|
||||
AiChatService,
|
||||
AiModelStreamService,
|
||||
],
|
||||
exports: [AiChatService],
|
||||
})
|
||||
export class AiChatModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,13 @@ export type AiSseEventName =
|
||||
| 'message.created'
|
||||
| 'reasoning.delta'
|
||||
| 'content.delta'
|
||||
| 'model.retrying'
|
||||
| 'tool.started'
|
||||
| 'tool.completed'
|
||||
| 'tool.failed'
|
||||
| 'ui.form'
|
||||
| 'ui.review'
|
||||
| 'ui.chart'
|
||||
| 'attachment.processed'
|
||||
| 'message.completed'
|
||||
| 'message.cancelled'
|
||||
@@ -40,4 +44,11 @@ export type ModelMessage =
|
||||
export type ModelStreamEvent =
|
||||
| { type: 'reasoning'; delta: string }
|
||||
| { type: 'content'; delta: string }
|
||||
| {
|
||||
type: 'retrying';
|
||||
attempt: number;
|
||||
maxRetries: number;
|
||||
delayMs: number;
|
||||
reason: string;
|
||||
}
|
||||
| { type: 'complete'; toolCalls: ModelToolCall[] };
|
||||
|
||||
235
apps/server/src/ai-chat/ai-excel-reader.service.ts
Normal file
235
apps/server/src/ai-chat/ai-excel-reader.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface ExcelSheetInfo {
|
||||
name: string;
|
||||
rowCount: number;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface ExcelSheetRows {
|
||||
name: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured Excel reader used by the AI chat. ExcelJS handles standard
|
||||
* files; a direct OOXML fallback handles WPS-style files that prefix
|
||||
* every element with a namespace. The agent reads sheets on demand
|
||||
* (`list_excel_sheets` / `read_excel_rows`) instead of receiving one
|
||||
* fixed text dump.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiExcelReaderService {
|
||||
async loadSheets(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
try {
|
||||
return await this.loadWithExcelJs(buffer);
|
||||
} catch {
|
||||
return this.loadWithFallback(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
async extractText(buffer: Buffer): Promise<string> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
return sheets
|
||||
.map((sheet) => `# ${sheet.name}\n${sheet.rows.map((row) => row.join('\t')).join('\n')}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Sheet list + row counts + a short sample, small enough for prompts. */
|
||||
async overview(
|
||||
buffer: Buffer,
|
||||
sampleRows = 12,
|
||||
): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const info = sheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
columns: sheet.rows[0] ?? [],
|
||||
}));
|
||||
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} 行未显示)`);
|
||||
}
|
||||
}
|
||||
return { sheets: info, text: lines.join('\n') };
|
||||
}
|
||||
|
||||
async readRows(
|
||||
buffer: Buffer,
|
||||
sheetName: string | undefined,
|
||||
startRow: number,
|
||||
rowCount: number,
|
||||
maxColumns: number,
|
||||
): Promise<{
|
||||
sheet: string;
|
||||
rowCount: number;
|
||||
startRow: number;
|
||||
rows: string[][];
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const sheets = await this.loadSheets(buffer);
|
||||
const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];
|
||||
if (!sheet) {
|
||||
return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
|
||||
}
|
||||
const from = Math.max(0, startRow - 1);
|
||||
const limit = Math.min(rowCount, 200);
|
||||
const slice = sheet.rows.slice(from, from + limit);
|
||||
const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
|
||||
return {
|
||||
sheet: sheet.name,
|
||||
rowCount: sheet.rows.length,
|
||||
startRow: from + 1,
|
||||
rows,
|
||||
truncated: slice.length < limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||
const sheets: ExcelSheetRows[] = [];
|
||||
workbook.eachSheet((sheet) => {
|
||||
const rows: string[][] = [];
|
||||
sheet.eachRow((row) => {
|
||||
const values = Array.isArray(row.values) ? row.values.slice(1) : [];
|
||||
rows.push(values.map((value) => this.stringifyCellValue(value)));
|
||||
});
|
||||
sheets.push({ name: sheet.name, rows });
|
||||
});
|
||||
return sheets;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
private stringifyCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
150
apps/server/src/ai-chat/ai-form.service.spec.ts
Normal file
150
apps/server/src/ai-chat/ai-form.service.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AiFormService } from './ai-form.service';
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}) {
|
||||
const forms = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (value) => value),
|
||||
create: jest.fn((value) => value),
|
||||
...overrides,
|
||||
};
|
||||
const service = new AiFormService(forms as never);
|
||||
return { service, forms };
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
userId: 7,
|
||||
conversationId: 3,
|
||||
assistantMessageId: 12,
|
||||
};
|
||||
|
||||
const validSchema = {
|
||||
title: '新增学生',
|
||||
description: '填写学生基本信息',
|
||||
submitLabel: '确认新增',
|
||||
fields: [
|
||||
{ name: 'name', label: '姓名', type: 'input', required: true },
|
||||
{ name: 'gender', label: '性别', type: 'select', options: [{ label: '男', value: '男' }, { label: '女', value: '女' }] },
|
||||
{ name: 'age', label: '年龄', type: 'number' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('AiFormService', () => {
|
||||
describe('createForm', () => {
|
||||
it('校验通过的 schema 落库并保留完整字段', async () => {
|
||||
const { service, forms } = createService();
|
||||
const form = await service.createForm(baseArgs, validSchema);
|
||||
expect(forms.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: 3,
|
||||
assistantMessageId: 12,
|
||||
title: '新增学生',
|
||||
submitLabel: '确认新增',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
expect(form.id).toBeTruthy();
|
||||
const fields = JSON.parse(form.fieldsJson) as unknown[];
|
||||
expect(fields).toHaveLength(3);
|
||||
expect(fields[1]).toEqual({
|
||||
name: 'gender',
|
||||
label: '性别',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: [{ label: '男', value: '男' }, { label: '女', value: '女' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('默认提交按钮文案为「提交」', async () => {
|
||||
const { service, forms } = createService();
|
||||
const { submitLabel, ...rest } = validSchema;
|
||||
await service.createForm(baseArgs, rest);
|
||||
expect(forms.create).toHaveBeenCalledWith(expect.objectContaining({ submitLabel: '提交' }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['标题缺失', { fields: validSchema.fields }, '表单标题'],
|
||||
['字段为空', { ...validSchema, fields: [] }, '至少需要一个字段'],
|
||||
['字段过多', { ...validSchema, fields: Array.from({ length: 13 }, (_, i) => ({ name: `f${i}`, label: `字段${i}`, type: 'input' })) }, '不能超过'],
|
||||
['类型非法', { ...validSchema, fields: [{ name: 'x', label: 'X', type: 'checkbox' }] }, '类型不支持'],
|
||||
['字段名非法', { ...validSchema, fields: [{ name: '姓 名', label: 'X', type: 'input' }] }, '只能包含'],
|
||||
['字段名重复', { ...validSchema, fields: [{ name: 'x', label: 'A', type: 'input' }, { name: 'x', label: 'B', type: 'input' }] }, '字段名重复'],
|
||||
['select 缺选项', { ...validSchema, fields: [{ name: 's', label: 'S', type: 'select' }] }, '选项数量'],
|
||||
['未知字段', { ...validSchema, extra: 1 }, '未知字段'],
|
||||
])('非法 schema 被拒绝:%s', async (_name, schema, messagePart) => {
|
||||
const { service } = createService();
|
||||
await expect(service.createForm(baseArgs, schema)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.createForm(baseArgs, schema)).rejects.toThrow(messagePart);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOwnedPending', () => {
|
||||
it('只返回本人 pending 表单', async () => {
|
||||
const form = { id: 'form-1', userId: 7, status: 'pending' };
|
||||
const { service, forms } = createService({ findOne: jest.fn().mockResolvedValue(form) });
|
||||
await expect(service.findOwnedPending('form-1', 7)).resolves.toBe(form);
|
||||
expect(forms.findOne).toHaveBeenCalledWith({ where: { id: 'form-1', userId: 7, status: 'pending' } });
|
||||
});
|
||||
|
||||
it('已提交或不存在时抛 NotFound', async () => {
|
||||
const { service } = createService({ findOne: jest.fn().mockResolvedValue(null) });
|
||||
await expect(service.findOwnedPending('form-1', 7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateValues', () => {
|
||||
const form = {
|
||||
fieldsJson: JSON.stringify(validSchema.fields),
|
||||
} as never;
|
||||
|
||||
it('通过合法值并丢弃空的可选字段', () => {
|
||||
const { service } = createService();
|
||||
const values = service.validateValues(form, { name: '张三', age: 18 });
|
||||
expect(values).toEqual({ name: '张三', age: 18 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['必填缺失', { age: 18 }, '「姓名」为必填项'],
|
||||
['未知字段', { name: '张三', hacker: 1 }, '未知字段'],
|
||||
['数字类型错误', { name: '张三', age: '18' }, '必须是数字'],
|
||||
['日期格式错误', { name: '张三', birthday: '18-01-2026' }, 'YYYY-MM-DD'],
|
||||
['选项越界', { name: '张三', gender: '未知' }, '选项无效'],
|
||||
])('非法值被拒绝:%s', async (_name, values, messagePart) => {
|
||||
const { service } = createService();
|
||||
const formWithDate = { fieldsJson: JSON.stringify([
|
||||
...validSchema.fields,
|
||||
{ name: 'birthday', label: '生日', type: 'date' },
|
||||
]) } as never;
|
||||
await expect(() => service.validateValues(formWithDate, values)).toThrow(messagePart);
|
||||
});
|
||||
|
||||
it('非对象提交被拒绝', () => {
|
||||
const { service } = createService();
|
||||
expect(() => service.validateValues(form, 'hacker')).toThrow(BadRequestException);
|
||||
expect(() => service.validateValues(form, ['hacker'])).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize', () => {
|
||||
it('回传前端所需结构', () => {
|
||||
const { service } = createService();
|
||||
const serialized = service.serialize({
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
description: null,
|
||||
submitLabel: '提交',
|
||||
fieldsJson: JSON.stringify(validSchema.fields),
|
||||
status: 'submitted',
|
||||
} as never);
|
||||
expect(serialized).toEqual({
|
||||
id: 'form-1',
|
||||
title: '新增学生',
|
||||
description: null,
|
||||
submitLabel: '提交',
|
||||
fields: validSchema.fields,
|
||||
status: 'submitted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
290
apps/server/src/ai-chat/ai-form.service.ts
Normal file
290
apps/server/src/ai-chat/ai-form.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
import { AiForm, type AiFormField } from './entities/ai-form.entity';
|
||||
|
||||
export const A2UI_FIELD_TYPES = ['input', 'textarea', 'number', 'select', 'date'] as const;
|
||||
|
||||
const MAX_TITLE = 50;
|
||||
const MAX_DESCRIPTION = 200;
|
||||
const MAX_SUBMIT_LABEL = 20;
|
||||
const MAX_FIELDS = 12;
|
||||
const MAX_NAME = 50;
|
||||
const MAX_LABEL = 50;
|
||||
const MAX_PLACEHOLDER = 100;
|
||||
const MAX_DEFAULT = 200;
|
||||
const MAX_OPTIONS = 20;
|
||||
const MAX_OPTION_TEXT = 50;
|
||||
const MAX_VALUE_LENGTH = 200;
|
||||
const MAX_VALUES_BYTES = 64 * 1024;
|
||||
|
||||
const FIELD_NAME_RE = /^[a-zA-Z0-9_]{1,50}$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const FIELD_KEYS = new Set([
|
||||
'name',
|
||||
'label',
|
||||
'type',
|
||||
'required',
|
||||
'placeholder',
|
||||
'defaultValue',
|
||||
'options',
|
||||
]);
|
||||
const SCHEMA_KEYS = new Set(['title', 'description', 'submitLabel', 'fields']);
|
||||
|
||||
interface ValidatedFormSchema {
|
||||
title: string;
|
||||
description: string | null;
|
||||
submitLabel: string;
|
||||
fields: AiFormField[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isShortString(value: unknown, max: number): value is string {
|
||||
return typeof value === 'string' && value.length <= max;
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
max: number,
|
||||
optional = false,
|
||||
): string {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) return '';
|
||||
throw new BadRequestException(`${label}不能为空`);
|
||||
}
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new BadRequestException(`${label}必须是字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > max) {
|
||||
throw new BadRequestException(`${label}长度不能超过 ${max}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side A2UI form lifecycle:
|
||||
* schema validation + persistence, owned lookup, submitted-value
|
||||
* validation, and serialization for SSE / message metadata.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiFormService {
|
||||
constructor(
|
||||
@InjectRepository(AiForm)
|
||||
private readonly forms: Repository<AiForm>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate `render_form` arguments and persist a pending form.
|
||||
* Throws BadRequestException when the schema is unsafe/invalid.
|
||||
*/
|
||||
async createForm(
|
||||
input: { userId: number; conversationId: number; assistantMessageId: number },
|
||||
rawArgs: unknown,
|
||||
): Promise<AiForm> {
|
||||
const schema = this.validateSchema(rawArgs);
|
||||
return this.forms.save(
|
||||
this.forms.create({
|
||||
id: uuidV7(),
|
||||
userId: input.userId,
|
||||
conversationId: input.conversationId,
|
||||
assistantMessageId: input.assistantMessageId,
|
||||
title: schema.title,
|
||||
description: schema.description,
|
||||
submitLabel: schema.submitLabel,
|
||||
fieldsJson: JSON.stringify(schema.fields),
|
||||
status: 'pending',
|
||||
submittedValuesJson: null,
|
||||
submittedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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('表单不存在或已提交');
|
||||
return form;
|
||||
}
|
||||
|
||||
async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
|
||||
form.status = 'submitted';
|
||||
form.submittedValuesJson = JSON.stringify(values);
|
||||
form.submittedAt = new Date();
|
||||
return this.forms.save(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate submitted values against the stored schema.
|
||||
* Returns a sanitized record containing only known field names.
|
||||
* Throws BadRequestException on invalid input.
|
||||
*/
|
||||
validateValues(form: AiForm, rawValues: unknown): Record<string, unknown> {
|
||||
if (!isPlainRecord(rawValues)) throw new BadRequestException('提交内容格式无效');
|
||||
|
||||
const fields = this.parseFields(form.fieldsJson);
|
||||
const known = new Set(fields.map((field) => field.name));
|
||||
for (const key of Object.keys(rawValues)) {
|
||||
if (!known.has(key)) throw new BadRequestException(`包含未知字段: ${key}`);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = rawValues[field.name];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (field.required) throw new BadRequestException(`「${field.label}」为必填项`);
|
||||
continue;
|
||||
}
|
||||
result[field.name] = this.normalizeValue(field, value);
|
||||
}
|
||||
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(result);
|
||||
} catch {
|
||||
throw new BadRequestException('提交内容无法序列化');
|
||||
}
|
||||
if (serialized.length > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Public shape sent via `ui.form` SSE and mirrored into message metadata. */
|
||||
serialize(form: AiForm): Record<string, unknown> {
|
||||
return {
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
submitLabel: form.submitLabel,
|
||||
fields: this.parseFields(form.fieldsJson),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
parseFields(fieldsJson: string): AiFormField[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fieldsJson);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is AiFormField => isPlainRecord(item));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchema(rawArgs: unknown): ValidatedFormSchema {
|
||||
if (!isPlainRecord(rawArgs)) throw new BadRequestException('表单参数必须是对象');
|
||||
for (const key of Object.keys(rawArgs)) {
|
||||
if (!SCHEMA_KEYS.has(key)) throw new BadRequestException(`表单包含未知字段: ${key}`);
|
||||
}
|
||||
|
||||
const title = requireString(rawArgs.title, '表单标题', MAX_TITLE);
|
||||
const description = requireString(rawArgs.description, '表单说明', MAX_DESCRIPTION, true) || null;
|
||||
const submitLabel = requireString(rawArgs.submitLabel, '提交按钮文案', MAX_SUBMIT_LABEL, true);
|
||||
|
||||
if (!Array.isArray(rawArgs.fields) || rawArgs.fields.length === 0) {
|
||||
throw new BadRequestException('表单至少需要一个字段');
|
||||
}
|
||||
if (rawArgs.fields.length > MAX_FIELDS) {
|
||||
throw new BadRequestException(`表单字段不能超过 ${MAX_FIELDS} 个`);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fields = rawArgs.fields.map((item, index) => this.validateField(item, index, seen));
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
submitLabel: submitLabel || '提交',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
private validateField(raw: unknown, index: number, seen: Set<string>): AiFormField {
|
||||
if (!isPlainRecord(raw)) throw new BadRequestException(`第 ${index + 1} 个字段格式无效`);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!FIELD_KEYS.has(key)) throw new BadRequestException(`字段包含未知属性: ${key}`);
|
||||
}
|
||||
|
||||
const name = requireString(raw.name, '字段名', MAX_NAME);
|
||||
if (!FIELD_NAME_RE.test(name)) {
|
||||
throw new BadRequestException(`字段名 ${name} 只能包含字母、数字、下划线`);
|
||||
}
|
||||
if (seen.has(name)) throw new BadRequestException(`字段名重复: ${name}`);
|
||||
seen.add(name);
|
||||
|
||||
const label = requireString(raw.label, '字段标签', MAX_LABEL);
|
||||
const type = raw.type;
|
||||
if (typeof type !== 'string' || !(A2UI_FIELD_TYPES as readonly string[]).includes(type)) {
|
||||
throw new BadRequestException(`字段 ${name} 的类型不支持`);
|
||||
}
|
||||
const fieldType = type as AiFormField['type'];
|
||||
|
||||
if (raw.required !== undefined && typeof raw.required !== 'boolean') {
|
||||
throw new BadRequestException(`字段 ${name} 的 required 必须是布尔值`);
|
||||
}
|
||||
const placeholder = requireString(raw.placeholder, `字段 ${name} 的 placeholder`, MAX_PLACEHOLDER, true);
|
||||
let defaultValue: string | number | undefined;
|
||||
if (raw.defaultValue !== undefined && raw.defaultValue !== null) {
|
||||
if (typeof raw.defaultValue === 'number') {
|
||||
if (!Number.isFinite(raw.defaultValue)) {
|
||||
throw new BadRequestException(`字段 ${name} 的 defaultValue 必须是有限数字`);
|
||||
}
|
||||
defaultValue = raw.defaultValue;
|
||||
} else if (isShortString(raw.defaultValue, MAX_DEFAULT)) {
|
||||
defaultValue = raw.defaultValue;
|
||||
} else {
|
||||
throw new BadRequestException(`字段 ${name} 的 defaultValue 无效`);
|
||||
}
|
||||
}
|
||||
|
||||
let options: Array<{ label: string; value: string }> | undefined;
|
||||
if (fieldType === 'select') {
|
||||
if (!Array.isArray(raw.options) || raw.options.length === 0 || raw.options.length > MAX_OPTIONS) {
|
||||
throw new BadRequestException(`字段 ${name} 的 select 选项数量必须在 1 到 ${MAX_OPTIONS} 之间`);
|
||||
}
|
||||
options = raw.options.map((option, optionIndex) => {
|
||||
if (!isPlainRecord(option)) {
|
||||
throw new BadRequestException(`字段 ${name} 第 ${optionIndex + 1} 个选项格式无效`);
|
||||
}
|
||||
const optionLabel = requireString(option.label, `字段 ${name} 的选项标签`, MAX_OPTION_TEXT);
|
||||
const optionValue = requireString(option.value, `字段 ${name} 的选项值`, MAX_OPTION_TEXT);
|
||||
return { label: optionLabel, value: optionValue };
|
||||
});
|
||||
} else if (raw.options !== undefined) {
|
||||
throw new BadRequestException(`字段 ${name} 只有 select 类型可以带 options`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
type: fieldType,
|
||||
required: raw.required === true,
|
||||
placeholder: placeholder || undefined,
|
||||
defaultValue,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeValue(field: AiFormField, value: unknown): unknown {
|
||||
if (field.type === 'number') {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestException(`「${field.label}」必须是数字`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'string' || value.length > MAX_VALUE_LENGTH) {
|
||||
throw new BadRequestException(`「${field.label}」格式无效`);
|
||||
}
|
||||
if (field.type === 'date' && !DATE_RE.test(value)) {
|
||||
throw new BadRequestException(`「${field.label}」必须是 YYYY-MM-DD 格式`);
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
const valid = field.options?.some((option) => option.value === value);
|
||||
if (!valid) throw new BadRequestException(`「${field.label}」选项无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const config: AiRuntimeConfig = {
|
||||
defaultModel: 'deepseek-reasoner',
|
||||
timeoutMs: 1000,
|
||||
enabled: true,
|
||||
supportsVision: false,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
|
||||
describe('AiModelStreamService', () => {
|
||||
@@ -55,6 +57,7 @@ describe('AiModelStreamService', () => {
|
||||
contentType: 'text/plain',
|
||||
body: body(),
|
||||
} as never);
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const consume = async () => {
|
||||
for await (const _ of service.stream(
|
||||
config,
|
||||
@@ -65,4 +68,97 @@ describe('AiModelStreamService', () => {
|
||||
};
|
||||
await expect(consume()).rejects.toThrow('AI 服务暂时不可用');
|
||||
});
|
||||
|
||||
it('上游 503 时自动重试并发出重试事件', async () => {
|
||||
async function* successBody() {
|
||||
yield Buffer.from('data: [DONE]\n\n');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
let calls = 0;
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'text/plain',
|
||||
body: { resume: jest.fn() },
|
||||
} as never;
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: successBody(),
|
||||
} as never;
|
||||
});
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const events: Array<{ type: string; attempt?: number; maxRetries?: number; reason?: string }> = [];
|
||||
for await (const event of service.stream(
|
||||
config,
|
||||
[{ role: 'user', content: '查询' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(calls).toBe(2);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: 'retrying', attempt: 1, maxRetries: 3, reason: '上游返回 503' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('上游 503 时提示服务繁忙', async () => {
|
||||
async function* body() {
|
||||
yield Buffer.from('{"error":{"message":"Service is too busy"}}');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockResolvedValue({
|
||||
status: 503,
|
||||
contentType: 'text/plain',
|
||||
body: body(),
|
||||
} as never);
|
||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
||||
const consume = async () => {
|
||||
for await (const _ of service.stream(
|
||||
config,
|
||||
[{ role: 'user', content: '查询' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
};
|
||||
await expect(consume()).rejects.toThrow('AI 服务繁忙,请稍后重试');
|
||||
});
|
||||
|
||||
it('配置 reasoningEffort 时仅对非 DeepSeek 服务商发送该参数', async () => {
|
||||
const bodies: string[] = [];
|
||||
async function* body() {
|
||||
yield Buffer.from('data: [DONE]\n\n');
|
||||
}
|
||||
const service = new AiModelStreamService();
|
||||
jest.spyOn(service as never, 'pinnedPost' as never).mockImplementation(
|
||||
async (_url: string, _headers: Record<string, string>, payload: string) => {
|
||||
bodies.push(payload);
|
||||
return { status: 200, contentType: 'text/event-stream', body: body() } as never;
|
||||
},
|
||||
);
|
||||
|
||||
for await (const _ of service.stream(
|
||||
{ ...config, reasoningEffort: 'high' },
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
expect(JSON.parse(bodies[0])).not.toHaveProperty('reasoning_effort');
|
||||
|
||||
for await (const _ of service.stream(
|
||||
{
|
||||
...config,
|
||||
provider: 'OPENAI' as AiRuntimeConfig['provider'],
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[],
|
||||
new AbortController().signal,
|
||||
)) void _;
|
||||
expect(JSON.parse(bodies[1]).reasoning_effort).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
||||
import { AiProvider } from '../ai-config/ai-config.entity';
|
||||
import type { ModelMessage, ModelStreamEvent } from './ai-chat.types';
|
||||
|
||||
interface ChatTool {
|
||||
@@ -26,6 +27,17 @@ interface StreamChoiceDelta {
|
||||
}
|
||||
|
||||
const MAX_UPSTREAM_EVENT_BYTES = 1024 * 1024;
|
||||
const MAX_UPSTREAM_RETRIES = 3;
|
||||
const UPSTREAM_RETRY_DELAYS_MS = [500, 1000, 2000];
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
const RETRYABLE_TRANSPORT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ETIMEDOUT',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'EPIPE',
|
||||
]);
|
||||
|
||||
// Known public provider hosts — trusted even if CDN resolves to private-range IPs
|
||||
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
|
||||
@@ -54,31 +66,76 @@ export class AiModelStreamService {
|
||||
tools: ChatTool[],
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ModelStreamEvent> {
|
||||
const timeout = AbortSignal.timeout(config.timeoutMs);
|
||||
const combinedSignal = AbortSignal.any([signal, timeout]);
|
||||
let response: PinnedResponse;
|
||||
const requestBody = JSON.stringify({
|
||||
model: config.defaultModel,
|
||||
messages,
|
||||
stream: true,
|
||||
...(tools.length ? { tools, tool_choice: 'auto' } : {}),
|
||||
// reasoning_effort 仅对支持该参数的 OpenAI 兼容服务生效;
|
||||
// DeepSeek 官方接口不接受该参数,避免请求被拒。
|
||||
...(config.reasoningEffort &&
|
||||
config.reasoningEffort !== 'none' &&
|
||||
config.provider !== AiProvider.DEEPSEEK
|
||||
? { reasoning_effort: config.reasoningEffort }
|
||||
: {}),
|
||||
});
|
||||
const url = `${config.baseUrl.replace(/\/$/, '')}/chat/completions`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
};
|
||||
let response: PinnedResponse | null = null;
|
||||
let activeTimeout: AbortSignal | undefined;
|
||||
|
||||
try {
|
||||
response = await this.pinnedPost(
|
||||
`${config.baseUrl.replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
JSON.stringify({
|
||||
model: config.defaultModel,
|
||||
messages,
|
||||
stream: true,
|
||||
...(tools.length ? { tools, tool_choice: 'auto' } : {}),
|
||||
}),
|
||||
combinedSignal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (timeout.aborted && !signal.aborted) throw new RequestTimeoutException('AI 服务响应超时');
|
||||
throw error;
|
||||
for (let attempt = 0; attempt <= MAX_UPSTREAM_RETRIES; attempt += 1) {
|
||||
activeTimeout = AbortSignal.timeout(config.timeoutMs);
|
||||
const combinedSignal = AbortSignal.any([signal, activeTimeout]);
|
||||
try {
|
||||
response = await this.pinnedPost(url, headers, requestBody, combinedSignal);
|
||||
} catch (error) {
|
||||
if (activeTimeout.aborted && !signal.aborted) {
|
||||
throw new RequestTimeoutException('AI 服务响应超时');
|
||||
}
|
||||
if (
|
||||
attempt < MAX_UPSTREAM_RETRIES &&
|
||||
!signal.aborted &&
|
||||
this.isRetryableTransportError(error)
|
||||
) {
|
||||
const delayMs = UPSTREAM_RETRY_DELAYS_MS[attempt];
|
||||
yield {
|
||||
type: 'retrying',
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_UPSTREAM_RETRIES,
|
||||
delayMs,
|
||||
reason: error instanceof Error ? error.message : '网络连接失败',
|
||||
};
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status >= 200 && response.status < 300) break;
|
||||
if (attempt < MAX_UPSTREAM_RETRIES && RETRYABLE_STATUS_CODES.has(response.status)) {
|
||||
response.body.resume?.();
|
||||
const delayMs = UPSTREAM_RETRY_DELAYS_MS[attempt];
|
||||
yield {
|
||||
type: 'retrying',
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_UPSTREAM_RETRIES,
|
||||
delayMs,
|
||||
reason: `上游返回 ${response.status}`,
|
||||
};
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
const body = await this.readLimitedBody(response.body);
|
||||
throw new BadGatewayException(this.safeUpstreamMessage(response.status, body));
|
||||
}
|
||||
|
||||
if (!response) throw new BadGatewayException('AI 服务暂时不可用');
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const body = await this.readLimitedBody(response.body);
|
||||
throw new BadGatewayException(this.safeUpstreamMessage(response.status, body));
|
||||
@@ -128,7 +185,9 @@ export class AiModelStreamService {
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) for (const parsed of consumeEvent(buffer)) yield parsed;
|
||||
} catch (error) {
|
||||
if (timeout.aborted && !signal.aborted) throw new RequestTimeoutException('AI 服务响应超时');
|
||||
if (activeTimeout?.aborted && !signal.aborted) {
|
||||
throw new RequestTimeoutException('AI 服务响应超时');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -154,9 +213,21 @@ export class AiModelStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
private isRetryableTransportError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code && RETRYABLE_TRANSPORT_CODES.has(code)) return true;
|
||||
return /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(error.message);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private safeUpstreamMessage(status: number, body: string): string {
|
||||
if (status === 401 || status === 403) return 'AI 服务认证失败';
|
||||
if (status === 429) return 'AI 服务请求过于频繁';
|
||||
if (status === 503) return 'AI 服务繁忙,请稍后重试';
|
||||
if (status >= 500) return 'AI 服务暂时不可用';
|
||||
const message = this.extractErrorMessage(body);
|
||||
return message ? `AI 服务请求失败:${message}` : `AI 服务请求失败(${status})`;
|
||||
|
||||
63
apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts
Normal file
63
apps/server/src/ai-chat/ai-review-enlarge.migration.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AddA2UiReviews1784880000000 } from '../migrations/1784880000000-AddA2UiReviews';
|
||||
import { EnlargeAiReviewSections1784900000000 } from '../migrations/1784900000000-EnlargeAiReviewSections';
|
||||
|
||||
describe('EnlargeAiReviewSections1784900000000', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
migrations: [AddA2UiReviews1784880000000, EnlargeAiReviewSections1784900000000],
|
||||
});
|
||||
await dataSource.initialize();
|
||||
await dataSource.query(`
|
||||
CREATE TABLE ai_messages (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id integer NOT NULL,
|
||||
role varchar(20) NOT NULL,
|
||||
content text,
|
||||
reasoning_content text,
|
||||
status varchar(20) NOT NULL,
|
||||
error_code varchar(50),
|
||||
reply_to_message_id integer,
|
||||
feedback varchar(10),
|
||||
feedback_reason varchar(500),
|
||||
metadata text,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (dataSource.isInitialized) await dataSource.destroy();
|
||||
});
|
||||
|
||||
it('迁移后可保存远超 256KB 的预览数据,且再次执行幂等', async () => {
|
||||
await dataSource.runMigrations();
|
||||
await dataSource.runMigrations();
|
||||
|
||||
await dataSource.query(
|
||||
`INSERT INTO ai_messages (conversation_id, role, content, status)
|
||||
VALUES (1, 'assistant', '', 'completed')`,
|
||||
);
|
||||
const big = '中'.repeat(300 * 1024);
|
||||
await dataSource.query(
|
||||
`INSERT INTO ai_reviews
|
||||
(id, conversation_id, user_id, assistant_message_id, title, sections_json, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
['review-1', 1, 1, 1, '大体积导入', big, 'pending'],
|
||||
);
|
||||
const rows: Array<{ sections_json: string }> = await dataSource.query(
|
||||
'SELECT sections_json FROM ai_reviews WHERE id = ?',
|
||||
['review-1'],
|
||||
);
|
||||
expect(rows[0].sections_json.length).toBe(big.length);
|
||||
|
||||
const runner = dataSource.createQueryRunner();
|
||||
expect(await runner.hasColumn('ai_reviews', 'sections_json')).toBe(true);
|
||||
await runner.release();
|
||||
});
|
||||
});
|
||||
1310
apps/server/src/ai-chat/ai-review.service.spec.ts
Normal file
1310
apps/server/src/ai-chat/ai-review.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
1838
apps/server/src/ai-chat/ai-review.service.ts
Normal file
1838
apps/server/src/ai-chat/ai-review.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';
|
||||
|
||||
export class CreateConversationDto {
|
||||
@IsOptional()
|
||||
@@ -58,11 +60,40 @@ export class SendMessageDto {
|
||||
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class RegenerateMessageDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class SubmitFormDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsObject()
|
||||
values: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class SubmitReviewDto {
|
||||
@IsUUID()
|
||||
clientRequestId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export class MessageFeedbackDto {
|
||||
|
||||
79
apps/server/src/ai-chat/entities/ai-form.entity.ts
Normal file
79
apps/server/src/ai-chat/entities/ai-form.entity.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AiMessage } from './ai-message.entity';
|
||||
|
||||
export type AiFormStatus = 'pending' | 'submitted';
|
||||
|
||||
export interface AiFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'input' | 'textarea' | 'number' | 'select' | 'date';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI dynamic form rendered inside an AI assistant message.
|
||||
*
|
||||
* The schema is validated server-side before persistence; submitted
|
||||
* values are validated again at submit time. Full schema copy is also
|
||||
* mirrored into the assistant message metadata (`a2uiForm`) so history
|
||||
* can render the form without a join.
|
||||
*/
|
||||
@Entity('ai_forms')
|
||||
@Index('idx_ai_forms_message', ['assistantMessageId'])
|
||||
@Index('idx_ai_forms_user_status', ['userId', 'status'])
|
||||
export class AiForm {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'integer' })
|
||||
conversationId: number;
|
||||
|
||||
@Column({ name: 'user_id', type: 'integer' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'assistant_message_id', type: 'integer' })
|
||||
assistantMessageId: number;
|
||||
|
||||
@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'assistant_message_id' })
|
||||
assistantMessage: AiMessage | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'submit_label', type: 'varchar', length: 20, default: '提交' })
|
||||
submitLabel: string;
|
||||
|
||||
@Column({ name: 'fields_json', type: 'text' })
|
||||
fieldsJson: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: AiFormStatus;
|
||||
|
||||
@Column({ name: 'submitted_values_json', type: 'text', nullable: true })
|
||||
submittedValuesJson: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'datetime', nullable: true })
|
||||
submittedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
95
apps/server/src/ai-chat/entities/ai-review.entity.ts
Normal file
95
apps/server/src/ai-chat/entities/ai-review.entity.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AiMessage } from './ai-message.entity';
|
||||
|
||||
export type AiReviewStatus = 'pending' | 'submitted' | 'expired';
|
||||
export type AiReviewSectionStatus = 'pending' | 'submitted' | 'failed' | 'skipped';
|
||||
export type AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins';
|
||||
|
||||
export interface AiReviewColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AiReviewRow {
|
||||
[key: string]: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface AiReviewSection {
|
||||
/** 唯一实例 ID(同一业务类型可有多张 sheet,每个 key 唯一) */
|
||||
key: string;
|
||||
/** 业务类型:students 学生 / rooms 宿舍 / transfers 换宿 / checkins 入住记录 */
|
||||
type: AiReviewSectionType;
|
||||
title: string;
|
||||
kind: 'table';
|
||||
/** 来源工作表名(可选) */
|
||||
sheet?: string;
|
||||
columns: AiReviewColumn[];
|
||||
rows: AiReviewRow[];
|
||||
issues: string[];
|
||||
status?: AiReviewSectionStatus;
|
||||
resultSummary?: string | null;
|
||||
submittedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI batch-import review rendered inside an AI assistant message.
|
||||
*
|
||||
* Holds the parsed & validated Excel rows grouped by business type; the same
|
||||
* type may appear in multiple sheets, each with a unique instance key. The
|
||||
* user reviews and confirms each sheet independently, or by type group, or all
|
||||
* at once. Sheet imports run in dependency order (students → rooms →
|
||||
* transfers → checkins), each in its own transaction.
|
||||
*/
|
||||
@Entity('ai_reviews')
|
||||
@Index('idx_ai_reviews_message', ['assistantMessageId'])
|
||||
@Index('idx_ai_reviews_user_status', ['userId', 'status'])
|
||||
export class AiReview {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'conversation_id', type: 'integer' })
|
||||
conversationId: number;
|
||||
|
||||
@Column({ name: 'user_id', type: 'integer' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'assistant_message_id', type: 'integer' })
|
||||
assistantMessageId: number;
|
||||
|
||||
@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'assistant_message_id' })
|
||||
assistantMessage: AiMessage | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
summary: string | null;
|
||||
|
||||
@Column({ name: 'sections_json', type: 'text' })
|
||||
sectionsJson: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: AiReviewStatus;
|
||||
|
||||
@Column({ name: 'result_summary', type: 'text', nullable: true })
|
||||
resultSummary: string | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'datetime', nullable: true })
|
||||
submittedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './ai-conversation.entity';
|
||||
export * from './ai-message.entity';
|
||||
export * from './ai-tool-run.entity';
|
||||
export * from './ai-attachment.entity';
|
||||
export * from './ai-form.entity';
|
||||
export * from './ai-review.entity';
|
||||
|
||||
19
apps/server/src/ai-chat/office-cli.service.spec.ts
Normal file
19
apps/server/src/ai-chat/office-cli.service.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
114
apps/server/src/ai-chat/office-cli.service.ts
Normal file
114
apps/server/src/ai-chat/office-cli.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface OfficeCliResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the OfficeCli binary
|
||||
* (https://github.com/iOfficeAI/OfficeCli) used by the AI chat to
|
||||
* analyze uploaded Office documents (.xlsx / .docx / .pptx) on demand.
|
||||
* Arguments are passed as an argv array (no shell), with a hard timeout
|
||||
* and a generous output cap.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OfficeCliService {
|
||||
private resolvedBinary: string | null = null;
|
||||
|
||||
async run(
|
||||
args: string[],
|
||||
options: { timeoutMs?: number; maxBuffer?: number } = {},
|
||||
): Promise<OfficeCliResult> {
|
||||
const binary = await this.resolveBinary();
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binary, args, {
|
||||
timeout: options.timeoutMs ?? 60_000,
|
||||
maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
|
||||
});
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
if (parsed && typeof parsed === 'object' && 'success' in parsed) {
|
||||
return parsed as OfficeCliResult;
|
||||
}
|
||||
return { success: true, data: parsed };
|
||||
} catch {
|
||||
return { success: false, error: 'OfficeCli 输出解析失败' };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `OfficeCli 执行失败:${message.slice(0, 200)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async view(
|
||||
filePath: string,
|
||||
mode: 'stats' | 'outline' | 'text' | 'issues',
|
||||
extra: string[] = [],
|
||||
): Promise<OfficeCliResult> {
|
||||
return this.run(['view', filePath, mode, '--json', ...extra]);
|
||||
}
|
||||
|
||||
async get(filePath: string, path: string, depth?: number): Promise<OfficeCliResult> {
|
||||
return this.run([
|
||||
'get',
|
||||
filePath,
|
||||
path,
|
||||
'--json',
|
||||
...(depth === undefined ? [] : ['--depth', String(depth)]),
|
||||
]);
|
||||
}
|
||||
|
||||
async query(filePath: string, selector: string): Promise<OfficeCliResult> {
|
||||
return this.run(['query', filePath, selector, '--json']);
|
||||
}
|
||||
|
||||
private async resolveBinary(): Promise<string> {
|
||||
if (this.resolvedBinary) return this.resolvedBinary;
|
||||
const candidates = [process.env.OFFICECLI_BIN, this.bundledBinary()].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await execFileAsync(candidate, ['--version'], { timeout: 5000 });
|
||||
this.resolvedBinary = candidate;
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException(
|
||||
'OfficeCli 未安装:请运行 npm install(@officecli/officecli),或通过 OFFICECLI_BIN 指定二进制路径',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the `@officecli/officecli` npm package (binary fetched by its
|
||||
* postinstall) so a fresh machine only needs `npm install`.
|
||||
*/
|
||||
private bundledBinary(): string | null {
|
||||
try {
|
||||
const mainPath = require.resolve('@officecli/officecli');
|
||||
const candidate = join(dirname(mainPath), '..', 'officecli.js');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// package not installed — fall through
|
||||
}
|
||||
for (const base of [process.cwd(), join(__dirname, '..', '..')]) {
|
||||
const candidate = join(base, 'node_modules', '@officecli', 'officecli', 'officecli.js');
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export class AiConfig {
|
||||
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
||||
timeoutMs: number;
|
||||
|
||||
@Column({ name: 'reasoning_effort', type: 'varchar', length: 20, nullable: true })
|
||||
reasoningEffort: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verified: boolean;
|
||||
|
||||
|
||||
@@ -482,6 +482,7 @@ export class AiConfigService {
|
||||
enabled: config.enabled,
|
||||
supportsVision: config.supportsVision,
|
||||
timeoutMs: config.timeoutMs,
|
||||
reasoningEffort: config.reasoningEffort ?? null,
|
||||
verified: config.verified,
|
||||
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
|
||||
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
|
||||
@@ -511,6 +512,10 @@ export class AiConfigService {
|
||||
config.timeoutMs = dto.timeoutMs;
|
||||
}
|
||||
|
||||
if (dto.reasoningEffort !== undefined) {
|
||||
config.reasoningEffort = dto.reasoningEffort || null;
|
||||
}
|
||||
|
||||
// Handle apiKey — empty/undefined = keep existing
|
||||
if (dto.apiKey !== undefined && dto.apiKey !== '') {
|
||||
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
|
||||
@@ -848,6 +853,7 @@ export class AiConfigService {
|
||||
timeoutMs: config.timeoutMs,
|
||||
enabled: config.enabled,
|
||||
supportsVision: config.supportsVision,
|
||||
reasoningEffort: config.reasoningEffort ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { AiProvider } from '../ai-config.entity';
|
||||
|
||||
const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COMPATIBLE] as const;
|
||||
export const REASONING_EFFORT_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
|
||||
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
|
||||
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
|
||||
@@ -51,6 +52,10 @@ export class SaveAiConfigDto {
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
/** DTO for POST /api/ai/config/test — all fields optional, validate only when provided */
|
||||
@@ -76,6 +81,10 @@ export class TestAiConfigDto {
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(REASONING_EFFORT_LEVELS)
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
/** Response shape for GET /api/ai/config — NEVER includes plaintext key */
|
||||
@@ -91,6 +100,7 @@ export interface AiConfigResponseDto {
|
||||
enabled: boolean;
|
||||
supportsVision: boolean;
|
||||
timeoutMs: number;
|
||||
reasoningEffort: string | null;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
lastTestLatencyMs: number | null;
|
||||
@@ -117,6 +127,7 @@ export interface AiRuntimeConfig {
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
supportsVision: boolean;
|
||||
reasoningEffort: string | null;
|
||||
}
|
||||
|
||||
/** DTO for POST /api/ai/config/models — fetch available model list from provider */
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
AiMessage,
|
||||
AiToolRun,
|
||||
AiAttachment,
|
||||
AiForm,
|
||||
AiReview,
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
@@ -64,6 +66,8 @@ import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddR
|
||||
import { AddJinshujuMatchRules1784700000000 } from './migrations/1784700000000-AddJinshujuMatchRules';
|
||||
import { AddAiChat1784780000000 } from './migrations/1784780000000-AddAiChat';
|
||||
import { EnhanceAiChatForAntDesignX1784860000000 } from './migrations/1784860000000-EnhanceAiChatForAntDesignX';
|
||||
import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiForms';
|
||||
import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews';
|
||||
const allMigrations = [
|
||||
InitialSchema1784520727860,
|
||||
AddExamManagement1784600000000,
|
||||
@@ -71,6 +75,8 @@ const allMigrations = [
|
||||
AddJinshujuMatchRules1784700000000,
|
||||
AddAiChat1784780000000,
|
||||
EnhanceAiChatForAntDesignX1784860000000,
|
||||
AddA2UiForms1784870000000,
|
||||
AddA2UiReviews1784880000000,
|
||||
];
|
||||
import { AuthorizationModule } from './authorization';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
@@ -181,6 +187,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
AiMessage,
|
||||
AiToolRun,
|
||||
AiAttachment,
|
||||
AiForm,
|
||||
AiReview,
|
||||
];
|
||||
if (dbType === 'mysql') {
|
||||
return {
|
||||
|
||||
@@ -77,6 +77,71 @@ export class ClassroomRentalsService {
|
||||
return rentals.map((rental) => this.withEffectiveStatus(rental));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent tool: 教室租赁订单查询,返回白名单字段。
|
||||
*/
|
||||
async agentSearchRentals(query?: {
|
||||
classroomId?: number;
|
||||
month?: string;
|
||||
includeEnded?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<
|
||||
{
|
||||
id: number;
|
||||
classroomName: string;
|
||||
lesseeOrganizationName: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
dailyRate: number | null;
|
||||
totalAmount: number | null;
|
||||
status: string;
|
||||
contractName: string | null;
|
||||
}[]
|
||||
> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoin('r.classroom', 'classroom')
|
||||
.leftJoin('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.select('r.id', 'id')
|
||||
.addSelect('classroom.name', 'classroomName')
|
||||
.addSelect('lesseeOrganization.name', 'lesseeOrganizationName')
|
||||
.addSelect('r.startDate', 'startDate')
|
||||
.addSelect('r.endDate', 'endDate')
|
||||
.addSelect('r.dailyRate', 'dailyRate')
|
||||
.addSelect('r.totalAmount', 'totalAmount')
|
||||
.addSelect('r.status', 'status')
|
||||
.addSelect('r.contractOriginalName', 'contractName');
|
||||
if (query?.classroomId) {
|
||||
qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
}
|
||||
if (query?.month) {
|
||||
const [y, m] = query.month.split('-').map(Number);
|
||||
const first = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||
}
|
||||
if (!query?.includeEnded) {
|
||||
qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE });
|
||||
}
|
||||
const rows = await qb
|
||||
.orderBy('r.startDate', 'DESC')
|
||||
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
|
||||
.getRawMany<Record<string, unknown>>();
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
classroomName: row.classroomName == null ? '' : String(row.classroomName),
|
||||
lesseeOrganizationName:
|
||||
row.lesseeOrganizationName == null ? null : String(row.lesseeOrganizationName),
|
||||
startDate: String(row.startDate),
|
||||
endDate: String(row.endDate),
|
||||
dailyRate: row.dailyRate == null ? null : Number(row.dailyRate),
|
||||
totalAmount: row.totalAmount == null ? null : Number(row.totalAmount),
|
||||
status: String(row.status),
|
||||
contractName: row.contractName == null ? null : String(row.contractName),
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const rental = await this.repo.findOne({
|
||||
where: { id },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user