feat: Excel 导入预检与动态问答,AI 聊天/文件解析体验修复
- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志 - ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志 - admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
This commit is contained in:
@@ -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,7 @@ export const depositSchema = z
|
||||
export const depositsSchema = z.array(depositSchema);
|
||||
|
||||
export const depositStudentLookupSchema = z
|
||||
.object({ studentId: z.number(), name: z.string().optional() })
|
||||
.object({ studentId: z.number(), name: z.string().nullable().optional() })
|
||||
.passthrough();
|
||||
|
||||
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
|
||||
@@ -217,6 +217,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())),
|
||||
|
||||
@@ -34,6 +34,18 @@ export const importStepDetailSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const importRunSettingsSchema = z
|
||||
.object({
|
||||
mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(),
|
||||
organization: z.string().nullable().optional(),
|
||||
updateExisting: z.boolean().optional(),
|
||||
duplicatePolicy: z.enum(['error', 'skip']).optional(),
|
||||
skipUnmatched: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
export const importRunDetailSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -44,6 +56,7 @@ export const importRunDetailSchema = z
|
||||
createdAt: z.string(),
|
||||
sheets: z.array(importSheetMetaSchema),
|
||||
steps: z.array(importStepDetailSchema),
|
||||
settings: importRunSettingsSchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
||||
// 断点首帧可能尚未解析(isMobile 误判为 true),桌面端默认展开会话侧边栏,
|
||||
// 移动端通过 effectiveSidebarOpen 统一隐藏。
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
|
||||
const [skills, setSkills] = useState<AiSkill[]>([]);
|
||||
const [conversationStatus, setConversationStatus] = useState<
|
||||
@@ -228,7 +230,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
},
|
||||
});
|
||||
},
|
||||
[setConversation],
|
||||
[setConversation, modal],
|
||||
);
|
||||
|
||||
/** 删除单个会话时中止请求并清理会话运行时状态 */
|
||||
@@ -269,13 +271,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
switchConversation,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
],
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
switchConversation,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
modal,
|
||||
],
|
||||
);
|
||||
|
||||
const enterSelectionMode = useCallback(() => {
|
||||
@@ -358,11 +361,12 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
activeId,
|
||||
conversations,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
switchConversation,
|
||||
setConversations,
|
||||
]);
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
switchConversation,
|
||||
setConversations,
|
||||
modal,
|
||||
]);
|
||||
|
||||
const conversationMenu = useCallback(
|
||||
(item: ConversationItemType): MenuProps => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import { ImportPreflightCard } from './ImportPreflightCard';
|
||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||
import { LiteMermaid } from './LiteMermaid';
|
||||
import type {
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiImportWizard,
|
||||
AiReviewSection,
|
||||
AiReviewSchema,
|
||||
@@ -43,6 +45,7 @@ const toolLabels: Record<string, string> = {
|
||||
render_form: '生成表单',
|
||||
render_review: '生成导入预览',
|
||||
render_chart: '生成图表',
|
||||
preflight_import: '导入预检',
|
||||
start_import_wizard: '生成导入向导',
|
||||
create_student: '创建学生',
|
||||
search_exams: '查询考试',
|
||||
@@ -311,6 +314,11 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
{attachmentCards}
|
||||
</Flex>
|
||||
)}
|
||||
{(() => {
|
||||
const preflight = message.metadata?.a2uiImportPreflight;
|
||||
if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null;
|
||||
return <ImportPreflightCard preflight={preflight as AiImportPreflight} />;
|
||||
})()}
|
||||
{(() => {
|
||||
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
|
||||
if (!wizard || !onOpenImportWizard) return null;
|
||||
|
||||
115
apps/admin/src/components/AiChat/ImportPreflightCard.tsx
Normal file
115
apps/admin/src/components/AiChat/ImportPreflightCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { TableOutlined } from '@ant-design/icons';
|
||||
import { Card, Flex, Space, Tag, Typography } from 'antd';
|
||||
import type {
|
||||
AiImportPreflight,
|
||||
AiImportPreflightVerdict,
|
||||
} from './types';
|
||||
|
||||
const VERDICT_META: Record<
|
||||
AiImportPreflightVerdict,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ready: { label: '可导入', color: 'success' },
|
||||
needs_input: { label: '需要确认', color: 'warning' },
|
||||
blocked: { label: '暂无法导入', color: 'error' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传 Excel 后的“可插入性预检报告”卡片:展示判定结论、
|
||||
* 分阶段统计、阻断原因、待确认问题与下一步建议。
|
||||
*/
|
||||
export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ({
|
||||
preflight,
|
||||
}) => {
|
||||
const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input;
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="ai-chat-preflight-card"
|
||||
title={
|
||||
<Flex gap={8} align="center" wrap>
|
||||
<TableOutlined />
|
||||
<Typography.Text strong>Excel 导入预检</Typography.Text>
|
||||
<Tag color={verdict.color}>{verdict.label}</Tag>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
{preflight.stages.length > 0 ? (
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
{preflight.stages.map((stage) => (
|
||||
<Flex key={stage.stepKey} vertical gap={4}>
|
||||
<Flex gap={8} align="center" wrap>
|
||||
<Typography.Text strong>{stage.label}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stage.sheetNames.join('、')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Space size={4} wrap>
|
||||
<Tag color="blue">共 {stage.total} 行</Tag>
|
||||
<Tag color="green">新建 {stage.create}</Tag>
|
||||
<Tag color="orange">更新 {stage.update}</Tag>
|
||||
<Tag color="red">错误 {stage.error}</Tag>
|
||||
<Tag>跳过 {stage.skip}</Tag>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未识别到可导入的工作表</Typography.Text>
|
||||
)}
|
||||
|
||||
{preflight.blocks.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
阻断原因
|
||||
</Typography.Text>
|
||||
{preflight.blocks.map((block) => (
|
||||
<Flex key={block.code} gap={8} align="baseline" wrap>
|
||||
<Tag color="red">{block.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{block.message}({block.count} 行)
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{preflight.questions.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
需要确认
|
||||
</Typography.Text>
|
||||
{preflight.questions.map((question) => (
|
||||
<Flex key={question.key} gap={8} align="baseline" wrap>
|
||||
<Tag color="gold">{question.label}</Tag>
|
||||
{question.description && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{question.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{preflight.nextSteps.length > 0 && (
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
下一步建议
|
||||
</Typography.Text>
|
||||
{preflight.nextSteps.map((step) => (
|
||||
<Flex key={step.key} gap={8} align="baseline" wrap>
|
||||
<Tag color="blue">{step.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{step.description}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -396,6 +396,142 @@ describe('AI chat bubble rendering', () => {
|
||||
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
||||
});
|
||||
|
||||
it('renders an import preflight card from assistant message metadata', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是预检结果',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [
|
||||
{
|
||||
key: 'students-next',
|
||||
label: '分班 / 排课 / 入住',
|
||||
description: '学生档案导入完成后可继续分班、排课或入住。',
|
||||
after: ['students'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('需要确认');
|
||||
expect(container.textContent).toContain('学生档案');
|
||||
expect(container.textContent).toContain('新建 1');
|
||||
expect(container.textContent).toContain('更新 1');
|
||||
expect(container.textContent).toContain('未知校区');
|
||||
expect(container.textContent).toContain('分班 / 排课 / 入住');
|
||||
});
|
||||
|
||||
it('renders preflight card, form Q&A and opens the import wizard', async () => {
|
||||
let openedRunId: string | null = null;
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '请先确认导入策略,再打开向导。',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'ready',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 1,
|
||||
create: 1,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
nextSteps: [],
|
||||
},
|
||||
a2uiImportWizard: {
|
||||
runId: 'run-1',
|
||||
fileName: 'students.xlsx',
|
||||
sheets: [{ name: '学生', headers: ['姓名'], rowCount: 1 }],
|
||||
steps: [{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }],
|
||||
},
|
||||
},
|
||||
forms: [
|
||||
{
|
||||
id: 'form-1',
|
||||
title: '确认导入策略',
|
||||
submitLabel: '确认',
|
||||
fields: [
|
||||
{
|
||||
name: 'duplicatePolicy',
|
||||
label: '重复行处理',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '标记为错误', value: 'error' },
|
||||
{ label: '跳过重复行', value: 'skip' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<AiMessageContent
|
||||
message={message}
|
||||
onOpenImportWizard={(runId) => {
|
||||
openedRunId = runId;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('确认导入策略');
|
||||
const wizardButton = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('打开导入向导'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(wizardButton).toBeDefined();
|
||||
await act(async () => {
|
||||
wizardButton?.click();
|
||||
});
|
||||
expect(openedRunId).toBe('run-1');
|
||||
});
|
||||
|
||||
it('renders model retrying hint while waiting for the upstream retry', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
|
||||
@@ -139,4 +139,26 @@ describe('AI chat history mapper', () => {
|
||||
expect(mapped.message.charts).toHaveLength(1);
|
||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
||||
});
|
||||
|
||||
it('keeps import preflight metadata on history messages', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [],
|
||||
};
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 8,
|
||||
role: 'assistant',
|
||||
content: '预检完成',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
});
|
||||
|
||||
expect(mapped.message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,47 @@ describe('AI chat SSE message reducer', () => {
|
||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
||||
});
|
||||
|
||||
it('stores ui.import_preflight in message metadata and restores it from completed message', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.import_preflight',
|
||||
data: JSON.stringify({ messageId: 8, preflight }),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 8,
|
||||
content: '预检完成',
|
||||
status: 'completed',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
|
||||
it('restores a persisted form from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
event: 'message.completed',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AiChatMessage,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiModelRetryInfo,
|
||||
AiReviewSchema,
|
||||
AiSseChunk,
|
||||
@@ -35,6 +36,7 @@ interface AiSsePayload {
|
||||
form?: AiFormSchema;
|
||||
review?: AiReviewSchema;
|
||||
chart?: AiChartSchema;
|
||||
preflight?: AiImportPreflight;
|
||||
wizard?: unknown;
|
||||
retry?: AiModelRetryInfo;
|
||||
message?:
|
||||
@@ -196,6 +198,8 @@ export function reduceAiSseMessage(
|
||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
||||
} else if (event === 'ui.chart' && payload.chart) {
|
||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||
} else if (event === 'ui.import_preflight' && payload.preflight) {
|
||||
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
|
||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||
} else if (event === 'tool.started') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,6 +115,61 @@ export interface AiImportWizard {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked';
|
||||
|
||||
export interface AiImportPreflightStage {
|
||||
stepKey: AiReviewSectionType;
|
||||
label: string;
|
||||
sheetNames: string[];
|
||||
total: number;
|
||||
create: number;
|
||||
update: number;
|
||||
error: number;
|
||||
skip: number;
|
||||
mapping: Record<string, string>;
|
||||
missingRequired: string[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflightBlock {
|
||||
code: string;
|
||||
label: string;
|
||||
stepKeys: AiReviewSectionType[];
|
||||
message: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightQuestion {
|
||||
key: string;
|
||||
type: 'mapping' | 'organization' | 'update' | 'duplicate' | 'reference';
|
||||
label: string;
|
||||
description?: string;
|
||||
stepKey?: AiReviewSectionType;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
default?: string | boolean;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightNextStep {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
after: AiReviewSectionType[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflight {
|
||||
verdict: AiImportPreflightVerdict;
|
||||
stages: AiImportPreflightStage[];
|
||||
blocks: AiImportPreflightBlock[];
|
||||
questions: AiImportPreflightQuestion[];
|
||||
nextSteps: AiImportPreflightNextStep[];
|
||||
errorSamples?: Array<{
|
||||
code: string;
|
||||
stepKey: AiReviewSectionType;
|
||||
sheet: string;
|
||||
rowNumber: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiToolRunStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ImportRunDetail {
|
||||
createdAt: string;
|
||||
sheets: ImportSheetMeta[];
|
||||
steps: ImportStepDetail[];
|
||||
settings?: {
|
||||
mapping?: Partial<Record<ImportStepKey, Record<string, string>>>;
|
||||
organization?: string | null;
|
||||
updateExisting?: boolean;
|
||||
duplicatePolicy?: 'error' | 'skip';
|
||||
skipUnmatched?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportStageRequest {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('加载费用数据失败');
|
||||
|
||||
Reference in New Issue
Block a user