diff --git a/apps/admin/src/api/schemas/core.ts b/apps/admin/src/api/schemas/core.ts index 38295e9..722e327 100644 --- a/apps/admin/src/api/schemas/core.ts +++ b/apps/admin/src/api/schemas/core.ts @@ -153,8 +153,8 @@ export const classroomSchema = z .object({ id: z.number(), name: z.string(), - building: z.string().optional(), - status: z.string().optional(), + building: z.string().nullable().optional(), + status: z.string().nullable().optional(), }) .passthrough(); @@ -165,7 +165,7 @@ export const studentSchema = z .object({ id: z.number(), name: z.string(), - studentNo: z.string().optional(), + studentNo: z.string().nullable().optional(), status: z.string(), }) .passthrough(); @@ -180,7 +180,11 @@ export const depositSchema = z export const depositsSchema = z.array(depositSchema); export const depositStudentLookupSchema = z - .object({ studentId: z.number(), name: z.string().optional() }) + .object({ + id: z.number(), + name: z.string().nullable().optional(), + studentNo: z.string().nullable().optional(), + }) .passthrough(); export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema); @@ -217,6 +221,18 @@ export const expenseRecordSchema = z export const expenseRecordsSchema = z.array(expenseRecordSchema); +export const expenseStudentLookupSchema = z + .object({ + id: z.number(), + name: z.string().nullable().optional(), + studentNo: z.string().nullable().optional(), + }) + .passthrough(); + +export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema); + +export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown())); + export const expenseLookupsSchema = z .object({ rooms: z.array(z.record(z.string(), z.unknown())), diff --git a/apps/admin/src/api/schemas/dashboard.ts b/apps/admin/src/api/schemas/dashboard.ts index 99a3ea0..d3dbce3 100644 --- a/apps/admin/src/api/schemas/dashboard.ts +++ b/apps/admin/src/api/schemas/dashboard.ts @@ -4,7 +4,8 @@ export const classroomScheduleSchema = z .object({ classrooms: z.array(z.record(z.string(), z.unknown())), organizations: z.array(z.record(z.string(), z.unknown())), - matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))), + // matrix: 教室 id → 日期 → 单条排课/租赁记录(对象,非数组) + matrix: z.record(z.string(), z.record(z.string(), z.record(z.string(), z.unknown()))), summary: z.record(z.string(), z.record(z.string(), z.unknown())), days: z.number().optional(), }) diff --git a/apps/admin/src/api/schemas/import-run.ts b/apps/admin/src/api/schemas/import-run.ts index 4ada227..2b2155e 100644 --- a/apps/admin/src/api/schemas/import-run.ts +++ b/apps/admin/src/api/schemas/import-run.ts @@ -34,6 +34,18 @@ export const importStepDetailSchema = z }) .passthrough(); +export const importRunSettingsSchema = z + .object({ + mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(), + organization: z.string().nullable().optional(), + updateExisting: z.boolean().optional(), + duplicatePolicy: z.enum(['error', 'skip']).optional(), + skipUnmatched: z.boolean().optional(), + }) + .passthrough() + .nullable() + .optional(); + export const importRunDetailSchema = z .object({ id: z.string(), @@ -44,6 +56,7 @@ export const importRunDetailSchema = z createdAt: z.string(), sheets: z.array(importSheetMetaSchema), steps: z.array(importStepDetailSchema), + settings: importRunSettingsSchema, }) .passthrough(); diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 44101e4..c179270 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -54,7 +54,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(!isMobile); + // 断点首帧可能尚未解析(isMobile 误判为 true),桌面端默认展开会话侧边栏, + // 移动端通过 effectiveSidebarOpen 统一隐藏。 + const [sidebarOpen, setSidebarOpen] = useState(true); const effectiveSidebarOpen = isMobile ? false : sidebarOpen; const [skills, setSkills] = useState([]); const [conversationStatus, setConversationStatus] = useState< @@ -228,7 +230,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }, }); }, - [setConversation], + [setConversation, modal], ); /** 删除单个会话时中止请求并清理会话运行时状态 */ @@ -269,13 +271,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }, }); }, - [ - activeId, - conversations, - switchConversation, - removeConversation, - removeConversationEntry, - ], + [ + activeId, + conversations, + switchConversation, + removeConversation, + removeConversationEntry, + modal, + ], ); const enterSelectionMode = useCallback(() => { @@ -358,11 +361,12 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting activeId, conversations, removeConversation, - removeConversationEntry, - selectedKeys, - switchConversation, - setConversations, - ]); + removeConversationEntry, + selectedKeys, + switchConversation, + setConversations, + modal, + ]); const conversationMenu = useCallback( (item: ConversationItemType): MenuProps => ({ diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index d5b5346..f176047 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -52,6 +52,9 @@ const toolLabels: Record = { search_classrooms: '查询教室', search_classroom_rentals: '查询教室租用', get_sync_status: '查询同步状态', + get_business_context: '读取业务流程', + get_entity_schema: '读取实体字典', + get_pending_tasks: '查询业务待办', }; const markdownComponents = { diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index eadf163..8148f58 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -84,6 +84,7 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = ); if (!form) return null; const finished = Boolean(form.submitted) || form.status === 'submitted'; + const expired = form.status === 'expired'; const handleFinish = (values: Record) => { onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); @@ -97,8 +98,10 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = {form.description} )} - {finished ? ( - + {expired ? ( + + ) : finished ? ( + ) : (
= ({ review, disabled, onActio )} @@ -341,7 +341,7 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio /> )} {activeStatus === 'failed' && ( - + )} {activeSection.resultSummary && activeStatus === 'submitted' && ( @@ -377,7 +377,7 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio )} - {submitted && } + {submitted && } {review.error && ( { expect(container.textContent).toContain('已提交'); }); + it('renders an expired A2UI form as disabled without submit action', async () => { + let submitted = false; + const el = document.createElement('div'); + container = el; + document.body.appendChild(el); + root = createRoot(el); + + await act(async () => { + root?.render( + { + submitted = true; + }} + />, + ); + }); + + expect(el.textContent).toContain('已失效'); + expect(el.querySelector('button[type="submit"]')).toBeNull(); + await act(async () => { + const buttons = Array.from(el.querySelectorAll('button')); + buttons.forEach((button) => button.click()); + }); + expect(submitted).toBe(false); + }); + it('renders an A2UI review card and submits via the confirm button', async () => { let submittedId: string | null = null; const review: AiReviewSchema = { diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 0aa813a..5363603 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,6 +78,52 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); + it('restores uiArtifacts from message metadata and derives legacy lists', () => { + const mapped = mapHistoryMessage({ + id: 8, + role: 'assistant', + content: '请处理', + reasoningContent: null, + status: 'completed', + errorCode: null, + createdAt: '2026-07-23T00:00:00.000Z', + metadata: { + uiArtifacts: [ + { + id: 'form-10', + type: 'form', + status: 'submitted', + messageId: 8, + conversationId: 3, + payload: { + id: 'form-10', + title: '新增学生', + status: 'submitted', + fields: [], + }, + }, + { + id: 'review-10', + type: 'review', + status: 'expired', + messageId: 8, + conversationId: 3, + payload: { + id: 'review-10', + title: '旧预览', + status: 'expired', + sections: [], + }, + }, + ], + }, + }); + + expect(mapped.message.uiArtifacts).toHaveLength(2); + expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); + expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + }); + it('restores a persisted A2UI review from message metadata', () => { const mapped = mapHistoryMessage({ id: 6, @@ -139,4 +185,5 @@ describe('AI chat history mapper', () => { expect(mapped.message.charts).toHaveLength(1); expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' }); }); + }); diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index e3a1390..ed7d0b0 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -3,11 +3,13 @@ import type { AiChatMessage, AiChatMessageStatus, AiChartSchema, + AiArtifactSchema, AiFormSchema, AiMessageRecord, AiReviewSchema, AiToolRun, } from './types'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; function mapStatus(record: AiMessageRecord): AiChatMessageStatus { if (record.status === 'pending') return 'loading'; @@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined { return [a2uiChart as AiChartSchema]; } +function historyArtifacts(record: AiMessageRecord) { + const artifacts = record.metadata?.uiArtifacts; + if (!Array.isArray(artifacts)) return undefined; + return artifacts.filter( + (item): item is AiArtifactSchema => + Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string', + ); +} + export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { + const artifacts = historyArtifacts(record); + const baseMessage = { + id: record.id, + role: record.role, + content: record.content || '', + reasoningContent: record.reasoningContent || '', + toolRuns: (record.toolRuns || []).map(normalizeToolRun), + attachments: record.attachments ?? [], + forms: historyForms(record), + reviews: historyReviews(record), + charts: historyCharts(record), + replyToMessageId: record.replyToMessageId, + metadata: record.metadata, + error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, + cancelled: record.status === 'cancelled', + }; + if (artifacts) { + for (const artifact of artifacts) { + mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact); + } + } return { id: record.id, status: mapStatus(record), - message: { - id: record.id, - role: record.role, - content: record.content || '', - reasoningContent: record.reasoningContent || '', - toolRuns: (record.toolRuns || []).map(normalizeToolRun), - attachments: record.attachments ?? [], - forms: historyForms(record), - reviews: historyReviews(record), - charts: historyCharts(record), - replyToMessageId: record.replyToMessageId, - metadata: record.metadata, - error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, - cancelled: record.status === 'cancelled', - }, + message: baseMessage as AiChatMessage, }; } diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index 7429bda..a0761d0 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -154,6 +154,42 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); }); + it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + let message = reduceAiSseMessage(undefined, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { id: 'form-1', title: '新增学生', fields: [] }, + }, + }), + }); + message = reduceAiSseMessage(message, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'review-1', + type: 'review', + status: 'expired', + messageId: 12, + conversationId: 3, + payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] }, + }, + }), + }); + + expect(message.uiArtifacts).toHaveLength(2); + expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); + expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); + }); + + it('restores a persisted form from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index ad93b6d..69b22f6 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -6,231 +6,10 @@ import { } from '@ant-design/x-sdk'; import { usePermissionStore } from '../../store/permission/permissionStore'; import { useUserStore } from '../../store/user/userStore'; -import type { - AiAttachment, - AiChatInput, - AiChatMessage, - AiChartSchema, - AiFormSchema, - AiModelRetryInfo, - AiReviewSchema, - AiSseChunk, - AiToolRun, -} from './types'; +import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types'; +import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer'; -interface AiSsePayload { - messageId?: number; - userMessageId?: number; - assistantMessageId?: number; - delta?: string; - content?: string; - reasoningContent?: string | null; - toolCallId?: string; - toolName?: string; - skillKey?: string | null; - status?: string; - summary?: string | null; - durationMs?: number | null; - attachment?: AiAttachment; - form?: AiFormSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; - wizard?: unknown; - retry?: AiModelRetryInfo; - message?: - | string - | { - id?: number; - content?: string; - reasoningContent?: string | null; - status?: string; - toolRuns?: AiToolRun[]; - attachments?: AiAttachment[]; - replyToMessageId?: number | null; - metadata?: Record | null; - }; - error?: string; -} - -function emptyAssistant(): AiChatMessage { - return { - role: 'assistant', - content: '', - reasoningContent: '', - toolRuns: [], - attachments: [], - forms: [], - }; -} - -function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - -function mergeById( - current: T[] | undefined, - incoming: T | T[] | undefined, -): T[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const index = next.findIndex((existing) => existing.id === item.id); - if (index === -1) { - next.push(item); - } else { - next[index] = item; - } - } - return next; -} - -export function parseSsePayload(chunk?: AiSseChunk): { - event: string; - payload: AiSsePayload; -} { - if (!chunk) return { event: '', payload: {} }; - const event = chunk.event?.trim() || 'message'; - if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; - try { - const parsed: unknown = JSON.parse(chunk.data); - return { - event, - payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, - }; - } catch { - return { event, payload: { delta: chunk.data } }; - } -} - -function upsertToolRun( - toolRuns: AiToolRun[], - payload: AiSsePayload, - fallbackStatus: AiToolRun['status'], -): AiToolRun[] { - const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; - const next: AiToolRun = { - toolCallId, - toolName: payload.toolName || '查询工具', - skillKey: payload.skillKey, - status: (payload.status as AiToolRun['status']) || fallbackStatus, - summary: payload.summary, - resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, - argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, - durationMs: payload.durationMs, - }; - const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); - if (index === -1) return [...toolRuns, next]; - return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); -} - -function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { - if (!toolRuns) return fallback; - return toolRuns.map((tool) => ({ - ...tool, - status: tool.status === 'error' ? 'failed' : tool.status, - summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, - })); -} - -function applyMessagePayload( - message: AiChatMessage, - nested: AiSsePayload['message'], - payload: AiSsePayload, -): void { - if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( - message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeById( - message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeById( - message.charts, - (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; - message.metadata = nested.metadata ?? message.metadata; -} - -export function reduceAiSseMessage( - originMessage: AiChatMessage | undefined, - chunk?: AiSseChunk, -): AiChatMessage { - const message = originMessage ? { ...originMessage } : emptyAssistant(); - const { event, payload } = parseSsePayload(chunk); - - if (event === 'message.created') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; - message.content = nested?.content ?? message.content; - message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - } else if (event === 'reasoning.delta') { - message.retrying = null; - message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; - } else if (event === 'content.delta') { - message.retrying = null; - message.content += payload.delta ?? payload.content ?? ''; - } else if (event === 'model.retrying' && payload.retry) { - message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeById(message.reviews, payload.review); - } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeById(message.charts, payload.chart); - } else if (event === 'ui.import_wizard' && payload.wizard) { - message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; - } else if (event === 'tool.started') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); - } else if (event === 'tool.completed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); - } else if (event === 'tool.failed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); - } else if (event === 'attachment.processed' && payload.attachment) { - if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { - message.attachments = [...message.attachments, payload.attachment]; - } - } else if (event === 'message.completed') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.messageId ?? message.id; - message.content = nested?.content ?? payload.content ?? message.content; - message.reasoningContent = - nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - message.retrying = null; - } else if (event === 'message.cancelled') { - message.id = payload.messageId ?? message.id; - message.cancelled = true; - message.retrying = null; - } else if (event === 'error') { - message.retrying = null; - message.error = - (typeof payload.message === 'string' ? payload.message : undefined) || - payload.error || - 'AI 回答生成失败'; - } - return message; -} +export { parseSsePayload, reduceAiSseMessage }; export async function authenticatedFetch( input: RequestInfo | URL, @@ -315,6 +94,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider< > { /** Routes events that target another (already streamed) message. */ onExternalReview?: (messageId: number, review: AiReviewSchema) => void; + onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void; constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { super({ @@ -400,6 +180,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider< transformMessage(info: TransformMessage): AiChatMessage { const { event, payload } = parseSsePayload(info.chunk); + if ( + event === 'ui.artifact' && + payload.artifact && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, payload.artifact); + return info.originMessage ?? emptyAssistant(); + } + if ( + event === 'ui.form' && + payload.form && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, { + id: payload.form.id, + type: 'form', + status: payload.form.status ?? 'pending', + messageId: payload.messageId, + payload: payload.form, + }); + return info.originMessage ?? emptyAssistant(); + } if ( event === 'ui.review' && payload.review && diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts new file mode 100644 index 0000000..e58a224 --- /dev/null +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -0,0 +1,204 @@ +import type { + AiArtifactSchema, + AiAttachment, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiModelRetryInfo, + AiReviewSchema, + AiSseChunk, + AiToolRun, +} from './types'; +import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; + +export interface AiSsePayload { + messageId?: number; + userMessageId?: number; + assistantMessageId?: number; + delta?: string; + content?: string; + reasoningContent?: string | null; + toolCallId?: string; + toolName?: string; + skillKey?: string | null; + status?: string; + summary?: string | null; + durationMs?: number | null; + attachment?: AiAttachment; + form?: AiFormSchema; + artifact?: AiArtifactSchema; + review?: AiReviewSchema; + chart?: AiChartSchema; + wizard?: unknown; + retry?: AiModelRetryInfo; + message?: + | string + | { + id?: number; + content?: string; + reasoningContent?: string | null; + status?: string; + toolRuns?: AiToolRun[]; + attachments?: AiAttachment[]; + replyToMessageId?: number | null; + metadata?: Record | null; + }; + error?: string; +} + +export function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + attachments: [], + forms: [], + uiArtifacts: [], + }; +} + +export function parseSsePayload(chunk?: AiSseChunk): { + event: string; + payload: AiSsePayload; +} { + if (!chunk) return { event: '', payload: {} }; + const event = chunk.event?.trim() || 'message'; + if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; + try { + const parsed: unknown = JSON.parse(chunk.data); + return { + event, + payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, + }; + } catch { + return { event, payload: { delta: chunk.data } }; + } +} + +function upsertToolRun( + toolRuns: AiToolRun[], + payload: AiSsePayload, + fallbackStatus: AiToolRun['status'], +): AiToolRun[] { + const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; + const next: AiToolRun = { + toolCallId, + toolName: payload.toolName || '查询工具', + skillKey: payload.skillKey, + status: (payload.status as AiToolRun['status']) || fallbackStatus, + summary: payload.summary, + resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, + argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, + durationMs: payload.durationMs, + }; + const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); + if (index === -1) return [...toolRuns, next]; + return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); +} + +function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { + if (!toolRuns) return fallback; + return toolRuns.map((tool) => ({ + ...tool, + status: tool.status === 'error' ? 'failed' : tool.status, + summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, + })); +} + +function applyMessagePayload( + message: AiChatMessage, + nested: AiSsePayload['message'], + payload: AiSsePayload, +): void { + if (typeof nested !== 'object' || nested === null) return; + message.forms = mergeForms( + message.forms, + (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + ); + message.reviews = mergeById( + message.reviews, + (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + ); + message.charts = mergeById( + message.charts, + (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + ); + const artifacts = nested.metadata?.uiArtifacts; + if (Array.isArray(artifacts)) { + for (const artifact of artifacts) { + if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') { + mergeArtifactIntoMessage(message, artifact as AiArtifactSchema); + } + } + } + message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; + message.metadata = nested.metadata ?? message.metadata; +} + +export function reduceAiSseMessage( + originMessage: AiChatMessage | undefined, + chunk?: AiSseChunk, +): AiChatMessage { + const message = originMessage ? { ...originMessage } : emptyAssistant(); + const { event, payload } = parseSsePayload(chunk); + + if (event === 'message.created') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; + message.content = nested?.content ?? message.content; + message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + } else if (event === 'reasoning.delta') { + message.retrying = null; + message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; + } else if (event === 'content.delta') { + message.retrying = null; + message.content += payload.delta ?? payload.content ?? ''; + } else if (event === 'model.retrying' && payload.retry) { + message.retrying = payload.retry; + } else if (event === 'ui.form' && payload.form) { + message.forms = mergeForms(message.forms, payload.form); + } else if (event === 'ui.review' && payload.review) { + message.reviews = mergeById(message.reviews, payload.review); + } else if (event === 'ui.chart' && payload.chart) { + message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.artifact' && payload.artifact) { + mergeArtifactIntoMessage(message, payload.artifact); + } else if (event === 'ui.import_wizard' && payload.wizard) { + message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; + } else if (event === 'tool.started') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); + } else if (event === 'tool.completed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); + } else if (event === 'tool.failed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); + } else if (event === 'attachment.processed' && payload.attachment) { + if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { + message.attachments = [...message.attachments, payload.attachment]; + } + } else if (event === 'message.completed') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.messageId ?? message.id; + message.content = nested?.content ?? payload.content ?? message.content; + message.reasoningContent = + nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + message.retrying = null; + } else if (event === 'message.cancelled') { + message.id = payload.messageId ?? message.id; + message.cancelled = true; + message.retrying = null; + } else if (event === 'error') { + message.retrying = null; + message.error = + (typeof payload.message === 'string' ? payload.message : undefined) || + payload.error || + 'AI 回答生成失败'; + } + return message; +} diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index 14a1e44..ab1ac75 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -165,6 +165,18 @@ min-height: 0; } +.ai-chat-main .ai-chat-toolbar { + order: 0; +} + +.ai-chat-main .ai-chat-messages { + order: 1; +} + +.ai-chat-main .ai-chat-composer { + order: 2; +} + .ai-chat-toolbar { display: flex; flex: 0 0 48px; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index a0964b1..aee0823 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -52,7 +52,7 @@ export interface AiFormSchema { description?: string | null; submitLabel?: string; fields: AiFormField[]; - status?: 'pending' | 'submitted'; + status?: 'pending' | 'submitted' | 'expired'; } export interface AiReviewColumn { @@ -98,6 +98,26 @@ export interface AiChartSchema { rows: AiReviewRow[]; } +export type AiArtifactType = + | 'form' + | 'review' + | 'chart' + | 'import_wizard'; + +export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; + +export interface AiArtifactSchema { + id: string; + type: AiArtifactType; + status: AiArtifactStatus; + messageId: number; + conversationId?: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +} + export interface AiImportWizard { runId: string; fileName: string; @@ -154,6 +174,7 @@ export interface AiChatMessage { forms?: AiFormSchema[]; reviews?: AiReviewSchema[]; charts?: AiChartSchema[]; + uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; retrying?: AiModelRetryInfo | null; diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts new file mode 100644 index 0000000..32f1f59 --- /dev/null +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -0,0 +1,67 @@ +import type { + AiArtifactSchema, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiReviewSchema, +} from './types'; + +export function mergeById( + current: T[] | undefined, + incoming: T | T[] | undefined, +): T[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const index = next.findIndex((existing) => existing.id === item.id); + if (index === -1) { + next.push(item); + } else { + next[index] = item; + } + } + return next; +} + +export function mergeForms( + current: AiFormSchema[] | undefined, + incoming: AiFormSchema | AiFormSchema[] | undefined, +): AiFormSchema[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { + next.push(item); + } + } + return next; +} + +function payloadOf(artifact: AiArtifactSchema): unknown { + return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; +} + +/** + * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 + * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + */ +export function mergeArtifactIntoMessage( + message: AiChatMessage, + artifact: AiArtifactSchema, +): AiChatMessage { + message.uiArtifacts = mergeById(message.uiArtifacts, artifact); + const payload = payloadOf(artifact); + if (artifact.type === 'form') { + message.forms = mergeForms(message.forms, payload as AiFormSchema); + } else if (artifact.type === 'review') { + message.reviews = mergeById(message.reviews, payload as AiReviewSchema); + } else if (artifact.type === 'chart') { + message.charts = mergeById(message.charts, payload as AiChartSchema); + } else if (artifact.type === 'import_wizard') { + message.metadata = { ...message.metadata, a2uiImportWizard: payload }; + } + return message; +} diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 274633c..4d7bdea 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -10,6 +10,7 @@ import { aiChatApi } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; import { emptyAssistant, MessageHoverActions, @@ -110,6 +111,11 @@ export function useAiChatMessageActions({ }, })); }; + provider.onExternalArtifact = (messageId, artifact) => { + setMessage(messageId, (info) => ({ + message: mergeArtifactIntoMessage(info.message, artifact), + })); + }; }, [provider, setMessage]); requestingRef.current = isRequesting; @@ -271,7 +277,7 @@ export function useAiChatMessageActions({ }, }); }, - [activeId, isRequesting, refreshConversations, removeMessage], + [activeId, isRequesting, refreshConversations, removeMessage, modal], ); const confirmEditMessage = useCallback( diff --git a/apps/admin/src/components/ECharts.tsx b/apps/admin/src/components/ECharts.tsx index 047acca..ab34151 100644 --- a/apps/admin/src/components/ECharts.tsx +++ b/apps/admin/src/components/ECharts.tsx @@ -50,13 +50,15 @@ interface EChartsProps { const ECharts: React.FC = ({ option, style, className, onReady }) => { const containerRef = useRef(null); + const optionRef = useRef(option); + optionRef.current = option; const onReadyRef = useRef(onReady); onReadyRef.current = onReady; useEffect(() => { if (!containerRef.current) return; const chart = echarts.init(containerRef.current); - chart.setOption(option); + chart.setOption(optionRef.current); onReadyRef.current?.(chart); const observer = new ResizeObserver(() => chart.resize()); observer.observe(containerRef.current); diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index a6618e7..223c8ac 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC = ({ try { const detail = await createImportRun(file, { source: 'manual', - stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }], + stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, }); await loadRun(detail.id); @@ -390,7 +390,7 @@ export const ImportWizardModal: React.FC = ({ = ({ ) : allCommitted ? ( - + = ({ )} ) : ( - + )} )} diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts index 097b361..4d390d6 100644 --- a/apps/admin/src/components/ImportWizard/types.ts +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -36,11 +36,21 @@ export interface ImportRunDetail { createdAt: string; sheets: ImportSheetMeta[]; steps: ImportStepDetail[]; + settings?: { + mapping?: Partial>>; + organization?: string | null; + updateExisting?: boolean; + duplicatePolicy?: 'error' | 'skip'; + skipUnmatched?: boolean; + }; } export interface ImportStageRequest { stepKey: ImportStepKey; + /** 兼容旧调用:单个工作表名。 */ sheet?: string; + /** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */ + sheets?: string[]; headerRow?: number; } diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index 4613ef0..c06fbe3 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -329,7 +329,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie open={open && canEnterModal} onCancel={handleClose} width={step === 'match' || step === 'applying' ? 900 : 640} - maskClosable={false} + mask={{ closable: false }} footer={ step === 'connection' ? [ diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 856f9e2..d1c2dfc 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool }, }); }, - [studentId, module], + [studentId, module, modal], ); } diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 1d27a1d..4a210b7 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -36,6 +36,7 @@ import api from '../api'; import { useAppStore } from '../store/app/appStore'; import { usePermissionStore } from '../store/permission/permissionStore'; import { useUserStore } from '../store/user/userStore'; +import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist'; import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; import RouteKeeper from '../components/RouteKeeper'; @@ -125,9 +126,42 @@ const MainLayout: React.FC = () => { }; const handleStorage = (event: StorageEvent) => { - if (event.key !== 'token' && event.key !== 'permissions') return; - usePermissionStore.getState().beginPermissionVerification(); - window.location.reload(); + if (event.key === PERMISSION_STORAGE_NAME) { + // 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。 + try { + if (event.newValue === null) { + usePermissionStore.getState().clearPermissions(); + return; + } + const parsed = JSON.parse(event.newValue) as { + state?: { permissions?: string[] }; + }; + const permissions = parsed?.state?.permissions; + if (Array.isArray(permissions)) { + usePermissionStore.getState().writePermissions(permissions); + } + } catch { + // 忽略无法解析的跨标签页权限写入 + } + return; + } + if (event.key === AUTH_STORAGE_NAME) { + // 仅当登录态(token)确实变化时才整页刷新:登录、退出或切换账号。 + const currentToken = useUserStore.getState().token; + let otherToken: string | null = null; + if (event.newValue) { + try { + const parsed = JSON.parse(event.newValue) as { + state?: { token?: string | null }; + }; + otherToken = parsed?.state?.token ?? null; + } catch { + otherToken = null; + } + } + if (otherToken === currentToken) return; + window.location.reload(); + } }; const handleOnline = () => verifyPermissions(); const handleVisibilityChange = () => { @@ -169,19 +203,22 @@ const MainLayout: React.FC = () => { navigate(key); if (usesDrawer) setDrawerOpen(false); }, - [navigate, usesDrawer], + [navigate, usesDrawer, setDrawerOpen], ); - const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => { - for (const item of items) { - if (item.key === pathname) return [item.key]; - if (item.children) { - const found = findSelectedKeys(item.children, pathname); - if (found.length > 0) return found; + const findSelectedKeys = useCallback( + (items: AppMenuItem[], pathname: string): string[] => { + for (const item of items) { + if (item.key === pathname) return [item.key]; + if (item.children) { + const found = findSelectedKeys(item.children, pathname); + if (found.length > 0) return found; + } } - } - return [pathname]; - }; + return [pathname]; + }, + [], + ); const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => { for (const item of items) { @@ -201,7 +238,7 @@ const MainLayout: React.FC = () => { const selectedKeys = useMemo( () => findSelectedKeys(menuItems, location.pathname), - [menuItems, location.pathname], + [menuItems, location.pathname, findSelectedKeys], ); // 路径变化时同步展开的菜单(不干扰用户手动展开/收起) useEffect(() => { @@ -210,20 +247,20 @@ const MainLayout: React.FC = () => { const routeOpenKeys = findOpenKeys(menuItems, location.pathname); setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]); } - }, [location.pathname, menuItems]); + }, [location.pathname, menuItems, setOpenKeys]); const handleOpenChange = useCallback((keys: string[]) => { setOpenKeys(keys); - }, []); + }, [setOpenKeys]); - const transformToMenuItems = (items: AppMenuItem[]): any[] => { + const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => { return items.map((item) => ({ key: item.key, icon: item.icon ? iconMap[item.icon] : undefined, label: item.label, children: item.children ? transformToMenuItems(item.children) : undefined, })); - }; + }, []); const menuContent = useMemo( () => ( { style={{ border: 'none' }} /> ), - [selectedKeys, openKeys, menuItems, handleMenuClick], + [selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems], ); return ( diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 7d05a2d..9a36429 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -37,7 +37,10 @@ const queryClient = new QueryClient({ }, }); -ReactDOM.createRoot(document.getElementById('root')!).render( +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('未找到 #root 挂载点'); + +ReactDOM.createRoot(rootElement).render( diff --git a/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx index cf802f4..7278312 100644 --- a/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx +++ b/apps/admin/src/pages/AiConfig/AiConfigSteps.tsx @@ -355,7 +355,7 @@ export const SaveTestStep: React.FC<{ 保存并测试} extra={}> diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index 2347cc9..d443e72 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -48,10 +48,10 @@ const DEFAULT_FORM_VALUES: FormValues = { }; const STEP_ITEMS = [ - { title: '服务商', description: '选择 AI 服务商' }, - { title: '密钥', description: '配置 API 密钥' }, - { title: '模型', description: '获取并选择模型' }, - { title: '完成', description: '保存并测试连接' }, + { title: '服务商', content: '选择 AI 服务商' }, + { title: '密钥', content: '配置 API 密钥' }, + { title: '模型', content: '获取并选择模型' }, + { title: '完成', content: '保存并测试连接' }, ]; const AiConfigPage: React.FC = () => { @@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => { } finally { setTesting(false); } - }, [formValues, form, currentProvider]); + }, [formValues, form, currentProvider, refreshConfig]); // ── Clear key ── @@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => { } }, }); - }, [config, modal]); + }, [config, modal, clearKeyMutation]); // ── Step navigation ── diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx index caa8316..ae8c78f 100644 --- a/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx +++ b/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx @@ -134,7 +134,7 @@ export const AttendanceAdminWorkspace: React.FC<{ - + rowKey="id" columns={columns} diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 1960c35..6768de6 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; @@ -115,10 +115,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit enabled: !!classId && !!attendanceDate, queryFn: async () => { try { + if (!attendanceDate) return []; return validateResponse( attendanceScheduleOptionsSchema, await api.get('/attendance-records/schedules', { - params: { classId, date: attendanceDate!.format('YYYY-MM-DD') }, + params: { classId, date: attendanceDate.format('YYYY-MM-DD') }, }), ); } catch (error: unknown) { @@ -133,10 +134,6 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit ? scheduleId : undefined; - useEffect(() => { - periodForm.setFieldsValue({ periods }); - }, [periods, periodForm]); - const enabledPeriods = useMemo( () => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder), [periods], @@ -214,7 +211,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit if (effectiveScheduleId) params.scheduleId = effectiveScheduleId; return params; }, - [page, pageSize, classId, attendanceDate, status, session, scheduleId], + [page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId], ); const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery({ @@ -334,7 +331,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit } finally { setRefreshingDingTalk(false); } - }, [attendanceDate, classId, loadRecords, loadSyncStatus, session]); + }, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]); const resetFilters = () => { setClassId(undefined); diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index a2ece16..7253958 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -4,6 +4,7 @@ import { useUserStore } from '../../store/user/userStore'; import { getAttendanceExperience } from './attendance-workspace'; import { TeacherAttendanceWorkspace } from './teacher'; import { AdminAttendanceArchive } from './admin'; +import './attendance.css'; function readCurrentRoles(): string[] { const roles = useUserStore.getState().user?.roles; diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index 24106d8..608c861 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { App, Table, @@ -139,7 +139,7 @@ const BillsPage: React.FC = () => { } }; - const showDetail = async (id: number) => { + const showDetail = useCallback(async (id: number) => { setDetailLoading(true); try { const res = await api.get(`/bills/${id}`); @@ -149,60 +149,69 @@ const BillsPage: React.FC = () => { } finally { setDetailLoading(false); } - }; + }, []); - const handleCancel = async (id: number) => { - let reason = ''; - modal.confirm({ - title: '取消账单并退回已扣余额', - content: ( - { - reason = event.target.value; - }} - /> - ), - okText: '确认取消', - cancelText: '返回', - onOk: async () => { - if (!reason.trim()) { - message.error('请输入取消原因'); - throw new Error('reason required'); - } - await cancelMutation.mutateAsync({ id, reason: reason.trim() }); - message.success('账单已取消,已扣余额已冲正退回'); - }, - }); - }; + const handleCancel = useCallback( + (id: number) => { + let reason = ''; + modal.confirm({ + title: '取消账单并退回已扣余额', + content: ( + { + reason = event.target.value; + }} + /> + ), + okText: '确认取消', + cancelText: '返回', + onOk: async () => { + if (!reason.trim()) { + message.error('请输入取消原因'); + throw new Error('reason required'); + } + await cancelMutation.mutateAsync({ id, reason: reason.trim() }); + message.success('账单已取消,已扣余额已冲正退回'); + }, + }); + }, + [modal, cancelMutation], + ); - const handleArchive = async (id: number) => { - try { - await archiveMutation.mutateAsync(id); - message.success('账单已归档'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleArchive = useCallback( + async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('账单已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); - const handlePurge = (id: number, studentName: string, period: string) => { - modal.confirm({ - title: `永久删除账单(${studentName} ${period})?`, - content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, studentName: string, period: string) => { + modal.confirm({ + title: `永久删除账单(${studentName} ${period})?`, + content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const batchArchive = async () => { if (selectedRows.length === 0) return message.warning('请先选择账单'); @@ -226,7 +235,7 @@ const BillsPage: React.FC = () => { ); }; - const handleExportPdf = async (billId: number) => { + const handleExportPdf = useCallback(async (billId: number) => { const printWindow = window.open('', '_blank'); if (!printWindow) { message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试'); @@ -245,7 +254,7 @@ const BillsPage: React.FC = () => { printWindow.close(); message.error(error?.message || '账单加载失败'); } - }; + }, []); const columns = useMemo( () => [ diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx index 7e72b76..155978d 100644 --- a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx @@ -531,22 +531,22 @@ export const ClassAttendanceTab: React.FC<{ {attendanceSummary && ( - + - + - + - + diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index e12af51..460ca94 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); - const handleArchive = async (id: number, archive: boolean) => { - try { - await archiveMutation.mutateAsync({ id, archive }); - message.success(archive ? '已归档' : '已恢复'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; - - const handlePurge = (record: ClassItem) => { - modal.confirm({ - title: `永久删除班级「${record.name}」?`, - content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(record.id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; - const { data = [], isLoading, @@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => { { invalidate: [['classes']] }, ); + const handleArchive = useCallback( + async (id: number, archive: boolean) => { + try { + await archiveMutation.mutateAsync({ id, archive }); + message.success(archive ? '已归档' : '已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); + + const handlePurge = useCallback( + (record: ClassItem) => { + modal.confirm({ + title: `永久删除班级「${record.name}」?`, + content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); + const filtered = useMemo(() => { if (!searchText) return data; const q = searchText.toLowerCase(); @@ -174,16 +180,19 @@ const ClassesPage: React.FC = () => { setModalOpen(true); }; - const handleEdit = (record: ClassItem) => { - setEditing(record); - form.setFieldsValue({ - ...record, - notes: record.notes ?? undefined, - startDate: record.startDate ? dayjs(record.startDate) : undefined, - endDate: record.endDate ? dayjs(record.endDate) : undefined, - }); - setModalOpen(true); - }; + const handleEdit = useCallback( + (record: ClassItem) => { + setEditing(record); + form.setFieldsValue({ + ...record, + notes: record.notes ?? undefined, + startDate: record.startDate ? dayjs(record.startDate) : undefined, + endDate: record.endDate ? dayjs(record.endDate) : undefined, + }); + setModalOpen(true); + }, + [form], + ); const handleSubmit = async () => { setSaving(true); @@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => { ), }, ], - [saveCell, canPurgeClass, handlePurge], + [saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive], ); return ( diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index d89d498..d2d5bc4 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => { } } }, - [], + [setUnavailableDates], ); const handleClassroomChange = (classroomId: number) => { diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index fd8101c..f6c2d10 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -65,7 +65,7 @@ const ClassroomSchedulePage: React.FC = () => { for (const c of data.classrooms) { const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`; if (!map.has(key)) map.set(key, []); - map.get(key)!.push(c); + map.get(key)?.push(c); } return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms })); }, [data]); diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index ecc4e4f..869b901 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; @@ -147,50 +147,63 @@ const ClassroomsPage: React.FC = () => { } }; - const saveCell = async (record: any, field: string, value: unknown) => { - try { - await saveCellMutation.mutateAsync({ record, field, value }); - message.success('已保存'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const saveCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [saveCellMutation], + ); - const handleArchive = async (id: number) => { - try { - await archiveMutation.mutateAsync(id); - message.success('已归档'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleArchive = useCallback( + async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); - const handleRestore = async (id: number) => { - try { - await restoreMutation.mutateAsync(id); - message.success('已恢复'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleRestore = useCallback( + async (id: number) => { + try { + await restoreMutation.mutateAsync(id); + message.success('已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [restoreMutation], + ); - const handlePurge = (id: number, name: string) => { - modal.confirm({ - title: `永久删除教室「${name}」?`, - content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, name: string) => { + modal.confirm({ + title: `永久删除教室「${name}」?`, + content: + '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD @@ -397,7 +410,7 @@ const ClassroomsPage: React.FC = () => { ), }, ], - [handlePurge, hasPermission], + [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form], ); return ( diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts b/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts new file mode 100644 index 0000000..678b0f6 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { shouldNavigateTodoCard } from './DashboardTodoCards'; + +describe('DashboardTodoCards 点击导航守卫', () => { + it('目标路径与当前路径不同且冷却已过时允许导航', () => { + expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1600)).toBe(true); + }); + + it('目标路径与当前路径相同时不允许导航', () => { + expect(shouldNavigateTodoCard('/attendance', '/attendance', 1000, 1600)).toBe(false); + }); + + it('冷却期内忽略重复点击,防止产生多条历史记录', () => { + expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1300)).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx index 9b71270..db49ce0 100644 --- a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Card, Col, Row } from 'antd'; import { ArrowRightOutlined, @@ -6,9 +6,22 @@ import { DollarOutlined, ExclamationCircleOutlined, } from '@ant-design/icons'; -import { useNavigate } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types'; +/** 冷却期内忽略重复点击,避免快速/幽灵点击压入多条历史记录。 */ +const TODO_CLICK_LOCK_MS = 400; + +export function shouldNavigateTodoCard( + currentPath: string, + targetPath: string, + lastNavAt: number, + now: number, +): boolean { + if (currentPath === targetPath) return false; + return now - lastNavAt >= TODO_CLICK_LOCK_MS; +} + export const DashboardTodoCards: React.FC<{ absentCount: number; draftCount: number; @@ -16,6 +29,14 @@ export const DashboardTodoCards: React.FC<{ pendingDeposits: number; }> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => { const navigate = useNavigate(); + const location = useLocation(); + const lastNavAtRef = useRef(0); + const go = (target: string) => { + const now = Date.now(); + if (!shouldNavigateTodoCard(location.pathname, target, lastNavAtRef.current, now)) return; + lastNavAtRef.current = now; + navigate(target); + }; return ( @@ -23,7 +44,7 @@ export const DashboardTodoCards: React.FC<{ 0 ? TODO_CARD_WARN : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/attendance')} + onClick={() => go('/attendance')} >
0 ? TODO_CARD_DRAFT : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/bills')} + onClick={() => go('/bills')} >
0 ? TODO_CARD_DANGER : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/deposits')} + onClick={() => go('/deposits')} >
{ aria-label="选择日期范围" value={[dayjs(period[0]), dayjs(period[1])]} onChange={(dates) => { - if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); + if (dates?.[0] && dates?.[1]) + setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); }} />
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 60babc3..20358bd 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -97,7 +97,11 @@ const DepositsPage: React.FC = () => { { invalidate: [['deposits']] }, ); const payInstallmentMutation = useApiMutation( - async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`), + async (installmentId: number) => + api.put(`/deposits/installments/${installmentId}`, { + status: 'paid', + paidDate: dayjs().format('YYYY-MM-DD'), + }), { invalidate: [['deposits']] }, ); const saveInstallmentCellMutation = useApiMutation( @@ -135,7 +139,7 @@ const DepositsPage: React.FC = () => { if (eligibleRoomType) params.roomType = eligibleRoomType; return validateResponse( eligibleStudentsSchema, - await api.get('/deposits/eligible', { params }), + await api.get('/deposits/eligible-students', { params }), ); }, }); diff --git a/apps/admin/src/pages/Exams/detail.tsx b/apps/admin/src/pages/Exams/detail.tsx index 05f90fc..314d969 100644 --- a/apps/admin/src/pages/Exams/detail.tsx +++ b/apps/admin/src/pages/Exams/detail.tsx @@ -163,7 +163,7 @@ const ExamDetailPage: React.FC = () => {
{detail.status === 'archived' ? ( - + ) : null} diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 53aad44..f1e7763 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -9,7 +9,12 @@ import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; -import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas'; +import { + expenseLookupsSchema, + expenseRecordsSchema, + expenseRoomsListSchema, + expenseStudentLookupsSchema, +} from '../../api/schemas'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; import { ExpenseTablePanel } from './ExpenseTablePanel'; import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; @@ -70,10 +75,10 @@ const ExpensesPage: React.FC = () => { try { const [rooms, personal, students, roomsList] = await Promise.all([ api.get('/expenses/room', { - params: expenseStatusForView(showArchived ? 'archived' : 'active'), + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/personal', { - params: expenseStatusForView(showArchived ? 'archived' : 'active'), + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/student-lookups'), api.get('/rooms'), @@ -81,8 +86,8 @@ const ExpensesPage: React.FC = () => { return { rooms: validateResponse(expenseRecordsSchema, rooms), personal: validateResponse(expenseRecordsSchema, personal), - students: validateResponse(expenseRecordsSchema, students), - roomsList: validateResponse(expenseRecordsSchema, roomsList), + students: validateResponse(expenseStudentLookupsSchema, students), + roomsList: validateResponse(expenseRoomsListSchema, roomsList), }; } catch { message.error('加载费用数据失败'); @@ -127,7 +132,7 @@ const ExpensesPage: React.FC = () => { { invalidate: [['expenses']] }, ), utility: useApiMutation( - async (payload: Record) => api.post('/expenses/utility', payload), + async (payload: Record) => api.post('/expenses/student-utility', payload), { invalidate: [['expenses'], ['bills']] }, ), importUtility: useApiMutation( @@ -161,11 +166,11 @@ const ExpensesPage: React.FC = () => { { invalidate: [['expenses']] }, ), batchRestoreRoom: useApiMutation( - async (ids: number[]) => api.post('/expenses/room/batch-restore', { ids }), + async (ids: number[]) => api.put('/expenses/room/batch-restore', { ids }), { invalidate: [['expenses']] }, ), batchRestorePersonal: useApiMutation( - async (ids: number[]) => api.post('/expenses/personal/batch-restore', { ids }), + async (ids: number[]) => api.put('/expenses/personal/batch-restore', { ids }), { invalidate: [['expenses']] }, ), purgeRoom: useApiMutation( diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 85470b3..b061b4a 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -171,7 +171,7 @@ const IntegrationConfigPage: React.FC = () => { diff --git a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx index 05109a6..ebac5d7 100644 --- a/apps/admin/src/pages/Occupancies/OccupancyModals.tsx +++ b/apps/admin/src/pages/Occupancies/OccupancyModals.tsx @@ -212,7 +212,7 @@ export const CheckInModal: React.FC<{ label="押金金额" rules={[{ required: true, message: '请输入押金金额' }]} > - + ) : null } diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 5b5abc1..f4d1f64 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -369,23 +369,26 @@ const OccupanciesPage: React.FC = () => { } }; - const handlePurge = (id: number, studentName: string) => { - modal.confirm({ - title: `永久删除入住记录(${studentName})?`, - content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, studentName: string) => { + modal.confirm({ + title: `永久删除入住记录(${studentName})?`, + content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const handleBatchPurge = async () => { if (batchLoading) return; diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index 60d83b2..55d7ea8 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -153,8 +153,8 @@ const OperationLogsPage: React.FC = () => { placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" onChange={(dates) => { - if (dates) - setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); + if (dates?.[0] && dates?.[1]) + setDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); else setDateRange(null); setPage(1); }} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 9603a8c..3b5f40b 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -349,7 +349,7 @@ const OrganizationsPage: React.FC = () => { @@ -417,7 +417,7 @@ const OrganizationsPage: React.FC = () => { ) : null} diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index e3da544..aeaa261 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -92,12 +92,15 @@ const RolesPage: React.FC = () => { setModalOpen(true); }; - const handleEdit = (record: RoleItem) => { - setEditing(record); - form.setFieldsValue({ name: record.name, description: record.description }); - setSelectedPermIds(record.permissions.map((p) => p.id)); - setModalOpen(true); - }; + const handleEdit = useCallback( + (record: RoleItem) => { + setEditing(record); + form.setFieldsValue({ name: record.name, description: record.description }); + setSelectedPermIds(record.permissions.map((p) => p.id)); + setModalOpen(true); + }, + [form], + ); const handleSubmit = async () => { setSaving(true); @@ -117,14 +120,17 @@ const RolesPage: React.FC = () => { } }; - const handleDisable = async (id: number) => { - try { - await disableMutation.mutateAsync(id); - message.success('角色已停用'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleDisable = useCallback( + async (id: number) => { + try { + await disableMutation.mutateAsync(id); + message.success('角色已停用'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [disableMutation], + ); const groupNames: Record = { dashboard: '数据面板', @@ -264,7 +270,7 @@ const RolesPage: React.FC = () => { ), }, ], - [permissionOptions, saveCell], + [permissionOptions, saveCell, handleEdit, handleDisable], ); const handleGroupCheckAll = (group: string, checked: boolean) => { diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 122471f..fd0c375 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -102,7 +102,7 @@ const RoomVisualPage: React.FC = () => { queryKey: ['rooms', 'visual', isHistorical, asOf], queryFn: async () => { try { - const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; + const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined; return await api.get('/rooms/visual', { params }); } catch (e: unknown) { message.error(getErrorMessage(e, '加载失败,请稍后重试')); @@ -229,7 +229,7 @@ const RoomVisualPage: React.FC = () => { showIcon icon={} style={{ marginBottom: 16 }} - title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`} + title={`正在查看 ${asOf ? asOf.format('YYYY年M月D日') : ''} 的历史入住情况(含当日已归档房间),非实时数据`} action={