fix(ai): 清理 aislop 扫描告警

- 拆分 SSE reducer 到 sseReducer.ts,provider.ts 降至 220 行
- artifact 合并工具抽到 uiArtifacts.ts,去除双重类型断言
- 业务上下文注册表改用 field/relation/entity 构建器收敛重复
- aislop 分数 71 → 81(Healthy),AI Slop 0 告警
This commit is contained in:
2026-08-06 15:35:12 +08:00
parent 24e0ecbdaf
commit c72ff2cb8a
7 changed files with 435 additions and 427 deletions

View File

@@ -9,7 +9,7 @@ import type {
AiReviewSchema,
AiToolRun,
} from './types';
import { mergeArtifactIntoMessage } from './provider';
import { mergeArtifactIntoMessage } from './uiArtifacts';
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
if (record.status === 'pending') return 'loading';

View File

@@ -6,280 +6,10 @@ import {
} from '@ant-design/x-sdk';
import { usePermissionStore } from '../../store/permission/permissionStore';
import { useUserStore } from '../../store/user/userStore';
import type {
AiAttachment,
AiArtifactSchema,
AiChatInput,
AiChatMessage,
AiChartSchema,
AiFormSchema,
AiImportPreflight,
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;
artifact?: AiArtifactSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
preflight?: AiImportPreflight;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
uiArtifacts: [],
};
}
function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
return next;
}
function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
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;
}
/**
* 将统一 artifact 归入 uiArtifacts并按类型派发到 legacy 列表。
*/
export function mergeArtifactIntoMessage(
message: AiChatMessage,
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
const payload =
artifact.payload && typeof artifact.payload === 'object'
? (artifact.payload as Record<string, unknown>)
: {};
if (artifact.type === 'form') {
message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema);
} else if (artifact.type === 'review') {
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
payload as unknown as AiReviewSchema,
);
} else if (artifact.type === 'chart') {
message.charts = mergeById<AiChartSchema>(
message.charts,
payload as unknown as AiChartSchema,
);
} else if (artifact.type === 'import_preflight') {
message.metadata = { ...message.metadata, a2uiImportPreflight: payload };
} else if (artifact.type === 'import_wizard') {
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
}
return message;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.artifact' && payload.artifact) {
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_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') {
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,

View File

@@ -0,0 +1,208 @@
import type {
AiArtifactSchema,
AiAttachment,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiImportPreflight,
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;
preflight?: AiImportPreflight;
wizard?: unknown;
retry?: AiModelRetryInfo;
message?:
| string
| {
id?: number;
content?: string;
reasoningContent?: string | null;
status?: string;
toolRuns?: AiToolRun[];
attachments?: AiAttachment[];
replyToMessageId?: number | null;
metadata?: Record<string, unknown> | null;
};
error?: string;
}
export function emptyAssistant(): AiChatMessage {
return {
role: 'assistant',
content: '',
reasoningContent: '',
toolRuns: [],
attachments: [],
forms: [],
uiArtifacts: [],
};
}
export function parseSsePayload(chunk?: AiSseChunk): {
event: string;
payload: AiSsePayload;
} {
if (!chunk) return { event: '', payload: {} };
const event = chunk.event?.trim() || 'message';
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
try {
const parsed: unknown = JSON.parse(chunk.data);
return {
event,
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
};
} catch {
return { event, payload: { delta: chunk.data } };
}
}
function upsertToolRun(
toolRuns: AiToolRun[],
payload: AiSsePayload,
fallbackStatus: AiToolRun['status'],
): AiToolRun[] {
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
const next: AiToolRun = {
toolCallId,
toolName: payload.toolName || '查询工具',
skillKey: payload.skillKey,
status: (payload.status as AiToolRun['status']) || fallbackStatus,
summary: payload.summary,
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
durationMs: payload.durationMs,
};
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
if (index === -1) return [...toolRuns, next];
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
}
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
if (!toolRuns) return fallback;
return toolRuns.map((tool) => ({
...tool,
status: tool.status === 'error' ? 'failed' : tool.status,
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
}));
}
function applyMessagePayload(
message: AiChatMessage,
nested: AiSsePayload['message'],
payload: AiSsePayload,
): void {
if (typeof nested !== 'object' || nested === null) return;
message.forms = mergeForms(
message.forms,
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeById<AiReviewSchema>(
message.reviews,
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeById<AiChartSchema>(
message.charts,
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
const artifacts = nested.metadata?.uiArtifacts;
if (Array.isArray(artifacts)) {
for (const artifact of artifacts) {
if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') {
mergeArtifactIntoMessage(message, artifact as AiArtifactSchema);
}
}
}
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
message.metadata = nested.metadata ?? message.metadata;
}
export function reduceAiSseMessage(
originMessage: AiChatMessage | undefined,
chunk?: AiSseChunk,
): AiChatMessage {
const message = originMessage ? { ...originMessage } : emptyAssistant();
const { event, payload } = parseSsePayload(chunk);
if (event === 'message.created') {
const nested = typeof payload.message === 'object' ? payload.message : undefined;
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
message.content = nested?.content ?? message.content;
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
applyMessagePayload(message, nested, payload);
} else if (event === 'reasoning.delta') {
message.retrying = null;
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
} else if (event === 'content.delta') {
message.retrying = null;
message.content += payload.delta ?? payload.content ?? '';
} else if (event === 'model.retrying' && payload.retry) {
message.retrying = payload.retry;
} else if (event === 'ui.form' && payload.form) {
message.forms = mergeForms(message.forms, payload.form);
} else if (event === 'ui.review' && payload.review) {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
} else if (event === 'ui.artifact' && payload.artifact) {
mergeArtifactIntoMessage(message, payload.artifact);
} else if (event === 'ui.import_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') {
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;
}

View File

@@ -0,0 +1,69 @@
import type {
AiArtifactSchema,
AiChartSchema,
AiChatMessage,
AiFormSchema,
AiReviewSchema,
} from './types';
export function mergeById<T extends { id: string }>(
current: T[] | undefined,
incoming: T | T[] | undefined,
): T[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const index = next.findIndex((existing) => existing.id === item.id);
if (index === -1) {
next.push(item);
} else {
next[index] = item;
}
}
return next;
}
export function mergeForms(
current: AiFormSchema[] | undefined,
incoming: AiFormSchema | AiFormSchema[] | undefined,
): AiFormSchema[] {
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
if (!items.length) return current ?? [];
const next = [...(current ?? [])];
for (const item of items) {
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
next.push(item);
}
}
return next;
}
function payloadOf(artifact: AiArtifactSchema): unknown {
return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {};
}
/**
* 将统一 artifact 归入 uiArtifacts并按类型派发到 legacy 列表。
* payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。
*/
export function mergeArtifactIntoMessage(
message: AiChatMessage,
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
const payload = payloadOf(artifact);
if (artifact.type === 'form') {
message.forms = mergeForms(message.forms, payload as AiFormSchema);
} else if (artifact.type === 'review') {
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
} else if (artifact.type === 'chart') {
message.charts = mergeById<AiChartSchema>(message.charts, payload as AiChartSchema);
} else if (artifact.type === 'import_preflight') {
message.metadata = { ...message.metadata, a2uiImportPreflight: payload };
} else if (artifact.type === 'import_wizard') {
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
}
return message;
}

View File

@@ -9,7 +9,8 @@ import { useSettingsStore } from '../../store/settings/settingsStore';
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider';
import { GongxueAiChatProvider } from './provider';
import { mergeArtifactIntoMessage } from './uiArtifacts';
import {
emptyAssistant,
MessageHoverActions,

View File

@@ -1,8 +1,49 @@
import type {
BusinessEntity,
BusinessEntityField,
BusinessEntityRelation,
BusinessWorkflow,
} from './business-context.types';
function field(
key: string,
label: string,
type: BusinessEntityField['type'],
extra: Partial<Omit<BusinessEntityField, 'key' | 'label' | 'type'>> = {},
): BusinessEntityField {
return { key, label, type, ...extra };
}
function relation(
entityKey: string,
via: string,
requiredFor: readonly string[],
): BusinessEntityRelation {
return { entityKey, via, requiredFor };
}
function entity(
key: string,
name: string,
description: string,
options: {
searchTool?: string;
requiredPermissions: readonly string[];
fields: readonly BusinessEntityField[];
relations?: readonly BusinessEntityRelation[];
},
): BusinessEntity {
return {
key,
name,
description,
...(options.searchTool ? { searchTool: options.searchTool } : {}),
requiredPermissions: options.requiredPermissions,
fields: options.fields,
relations: options.relations ?? [],
};
}
/**
* 恭学系统业务上下文(代码内维护)。
*
@@ -11,232 +52,191 @@ import type {
* Agent 感知业务流程。
*/
export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [
{
key: 'student',
name: '学生档案',
description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。',
entity('student', '学生档案', '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', {
searchTool: 'search_students',
requiredPermissions: ['student:view'],
fields: [
{ key: 'name', label: '姓名', type: 'string', required: true },
{ key: 'studentNo', label: '学号', type: 'string' },
{ key: 'phone', label: '手机号', type: 'string' },
{ key: 'gender', label: '性别', type: 'enum', enumFrom: 'student.gender' },
{ key: 'idNumber', label: '身份证号', type: 'string' },
field('name', '姓名', 'string', { required: true }),
field('studentNo', '学号', 'string'),
field('phone', '手机号', 'string'),
field('gender', '性别', 'enum', { enumFrom: 'student.gender' }),
field('idNumber', '身份证号', 'string'),
],
relations: [
{ entityKey: 'class', via: 'class_student', requiredFor: ['class'] },
{ entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'bill', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'exam', via: 'exam_score', requiredFor: ['exam'] },
relation('class', 'class_student', ['class']),
relation('occupancy', 'occupancy', ['checkin']),
relation('bill', 'bill', ['bill']),
relation('exam', 'exam_score', ['exam']),
],
},
{
key: 'class',
name: '班级',
description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。',
}),
entity('class', '班级', '班级档案与在读分班关系,排课、考勤、考试依赖班级。', {
searchTool: 'search_classes',
requiredPermissions: ['class:view'],
fields: [
{ key: 'name', label: '班级名称', type: 'string', required: true },
{ key: 'grade', label: '年级', type: 'string' },
{ key: 'headTeacher', label: '班主任', type: 'string' },
field('name', '班级名称', 'string', { required: true }),
field('grade', '年级', 'string'),
field('headTeacher', '班主任', 'string'),
],
relations: [
{ entityKey: 'student', via: 'class_student', requiredFor: ['class'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
relation('student', 'class_student', ['class']),
relation('schedule', 'class_schedule', ['schedule']),
],
},
{
key: 'schedule',
name: '排课/日程',
description: '班级与教室的课程安排,考勤和教室日程依赖排课。',
}),
entity('schedule', '排课/日程', '班级与教室的课程安排,考勤和教室日程依赖排课。', {
searchTool: 'search_schedules',
requiredPermissions: ['schedule:view'],
fields: [
{ key: 'classId', label: '班级', type: 'number' },
{ key: 'classroomId', label: '教室', type: 'number' },
{ key: 'weekDay', label: '星期', type: 'number' },
{ key: 'startTime', label: '开始时间', type: 'string' },
{ key: 'endTime', label: '结束时间', type: 'string' },
field('classId', '班级', 'number'),
field('classroomId', '教室', 'number'),
field('weekDay', '星期', 'number'),
field('startTime', '开始时间', 'string'),
field('endTime', '结束时间', 'string'),
],
relations: [
{ entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] },
{ entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] },
relation('class', 'class_schedule', ['schedule']),
relation('classroom', 'class_schedule', ['schedule']),
],
},
{
key: 'attendance',
name: '考勤',
description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。',
}),
entity('attendance', '考勤', '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', {
searchTool: 'get_attendance_summary',
requiredPermissions: ['attendance:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'date', label: '日期', type: 'date', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'attendance.status' },
field('studentId', '学生', 'number', { required: true }),
field('date', '日期', 'date', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'attendance.status' }),
],
relations: [
{ entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] },
relation('class', 'class_schedule', ['attendance']),
relation('schedule', 'class_schedule', ['attendance']),
],
},
{
key: 'exam',
name: '考试/成绩',
description: '考试安排与成绩记录,依赖班级与学生档案。',
}),
entity('exam', '考试/成绩', '考试安排与成绩记录,依赖班级与学生档案。', {
searchTool: 'search_exams',
requiredPermissions: ['exam:view'],
fields: [
{ key: 'name', label: '考试名称', type: 'string', required: true },
{ key: 'date', label: '考试日期', type: 'date' },
{ key: 'subject', label: '科目', type: 'string' },
field('name', '考试名称', 'string', { required: true }),
field('date', '考试日期', 'date'),
field('subject', '科目', 'string'),
],
relations: [
{ entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] },
{ entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] },
relation('class', 'exam_score', ['exam']),
relation('student', 'exam_score', ['exam']),
],
},
{
key: 'room',
name: '宿舍档案',
description: '宿舍/床位基础档案,入住登记的前置数据。',
}),
entity('room', '宿舍档案', '宿舍/床位基础档案,入住登记的前置数据。', {
searchTool: 'search_rooms',
requiredPermissions: ['room:view'],
fields: [
{ key: 'roomNumber', label: '宿舍号', type: 'string', required: true },
{ key: 'building', label: '楼栋', type: 'string' },
{ key: 'floor', label: '楼层', type: 'number' },
{ key: 'capacity', label: '容量', type: 'number', required: true },
{ key: 'roomType', label: '房型', type: 'string' },
{ key: 'monthlyRate', label: '月租金', type: 'number' },
field('roomNumber', '宿舍号', 'string', { required: true }),
field('building', '楼栋', 'string'),
field('floor', '楼层', 'number'),
field('capacity', '容量', 'number', { required: true }),
field('roomType', '房型', 'string'),
field('monthlyRate', '月租金', 'number'),
],
relations: [
{ entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] },
],
},
{
key: 'occupancy',
name: '入住记录',
description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。',
relations: [relation('occupancy', 'occupancy', ['checkin'])],
}),
entity('occupancy', '入住记录', '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', {
searchTool: 'get_room_occupancy_summary',
requiredPermissions: ['occupancy:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'roomId', label: '宿舍', type: 'number', required: true },
{ key: 'checkInDate', label: '入住日期', type: 'date', required: true },
{ key: 'billingStartDate', label: '计费开始日期', type: 'date', required: true },
{ key: 'stayType', label: '住宿类型', type: 'enum', enumFrom: 'occupancy.stayType' },
field('studentId', '学生', 'number', { required: true }),
field('roomId', '宿舍', 'number', { required: true }),
field('checkInDate', '入住日期', 'date', { required: true }),
field('billingStartDate', '计费开始日期', 'date', { required: true }),
field('stayType', '住宿类型', 'enum', { enumFrom: 'occupancy.stayType' }),
],
relations: [
{ entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] },
{ entityKey: 'bill', via: 'bill', requiredFor: ['bill'] },
relation('student', 'occupancy', ['checkin']),
relation('room', 'occupancy', ['checkin']),
relation('bill', 'bill', ['bill']),
],
},
{
key: 'expense',
name: '费用',
description: '公共费用与个人费用,是生成账单的基础。',
}),
entity('expense', '费用', '公共费用与个人费用,是生成账单的基础。', {
searchTool: 'search_expenses',
requiredPermissions: ['expense:view'],
fields: [
{ key: 'type', label: '费用类型', type: 'string', required: true },
{ key: 'amount', label: '金额', type: 'number', required: true },
{ key: 'periodStart', label: '费用开始日期', type: 'date' },
{ key: 'periodEnd', label: '费用结束日期', type: 'date' },
field('type', '费用类型', 'string', { required: true }),
field('amount', '金额', 'number', { required: true }),
field('periodStart', '费用开始日期', 'date'),
field('periodEnd', '费用结束日期', 'date'),
],
relations: [
{ entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] },
{ entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] },
relation('occupancy', 'expense', ['expense']),
relation('bill', 'bill_item', ['bill']),
],
},
{
key: 'bill',
name: '账单',
description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。',
}),
entity('bill', '账单', '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', {
searchTool: 'search_bills',
requiredPermissions: ['bill:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'periodStart', label: '账期开始', type: 'date', required: true },
{ key: 'periodEnd', label: '账期结束', type: 'date', required: true },
{ key: 'totalAmount', label: '总金额', type: 'number', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'bill.status' },
field('studentId', '学生', 'number', { required: true }),
field('periodStart', '账期开始', 'date', { required: true }),
field('periodEnd', '账期结束', 'date', { required: true }),
field('totalAmount', '总金额', 'number', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'bill.status' }),
],
relations: [
{ entityKey: 'student', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] },
{ entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] },
relation('student', 'bill', ['bill']),
relation('occupancy', 'bill', ['bill']),
relation('deposit', 'deposit', ['deposit']),
],
},
{
key: 'deposit',
name: '押金',
description: '押金收取与退还记录,通常在账单确认后处理。',
}),
entity('deposit', '押金', '押金收取与退还记录,通常在账单确认后处理。', {
searchTool: 'search_deposits',
requiredPermissions: ['deposit:view'],
fields: [
{ key: 'studentId', label: '学生', type: 'number', required: true },
{ key: 'amount', label: '金额', type: 'number', required: true },
{ key: 'status', label: '状态', type: 'enum', enumFrom: 'deposit.status' },
field('studentId', '学生', 'number', { required: true }),
field('amount', '金额', 'number', { required: true }),
field('status', '状态', 'enum', { enumFrom: 'deposit.status' }),
],
relations: [
{ entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] },
{ entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] },
relation('student', 'deposit', ['deposit']),
relation('bill', 'deposit', ['deposit']),
],
},
{
key: 'classroom',
name: '教室档案',
description: '教室基础档案,租赁与教室日程的前置数据。',
}),
entity('classroom', '教室档案', '教室基础档案,租赁与教室日程的前置数据。', {
searchTool: 'search_classrooms',
requiredPermissions: ['classroom:view'],
fields: [
{ key: 'name', label: '教室名称', type: 'string', required: true },
{ key: 'building', label: '楼栋', type: 'string' },
{ key: 'capacity', label: '容量', type: 'number' },
field('name', '教室名称', 'string', { required: true }),
field('building', '楼栋', 'string'),
field('capacity', '容量', 'number'),
],
relations: [
{ entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
relation('rental', 'classroom_rental', ['rental']),
relation('schedule', 'class_schedule', ['schedule']),
],
},
{
key: 'organization',
name: '组织/校区',
description: '校区与组织归属,租赁双方与档案归属依赖组织。',
}),
entity('organization', '组织/校区', '校区与组织归属,租赁双方与档案归属依赖组织。', {
requiredPermissions: ['organization:view'],
fields: [
{ key: 'name', label: '名称', type: 'string', required: true },
{ key: 'code', label: '编码', type: 'string' },
{ key: 'isHost', label: '是否本部', type: 'boolean' },
field('name', '名称', 'string', { required: true }),
field('code', '编码', 'string'),
field('isHost', '是否本部', 'boolean'),
],
relations: [
{ entityKey: 'student', via: 'organization', requiredFor: ['profile'] },
{ entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] },
relation('student', 'organization', ['profile']),
relation('rental', 'classroom_rental', ['rental']),
],
},
{
key: 'rental',
name: '教室租赁',
description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。',
}),
entity('rental', '教室租赁', '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', {
searchTool: 'search_classroom_rentals',
requiredPermissions: ['rental:view'],
fields: [
{ key: 'classroomId', label: '教室', type: 'number', required: true },
{ key: 'lesseeOrganizationId', label: '承租方', type: 'number' },
{ key: 'startDate', label: '开始日期', type: 'date', required: true },
{ key: 'endDate', label: '结束日期', type: 'date', required: true },
{ key: 'dailyRate', label: '日租金', type: 'number' },
{ key: 'contractPath', label: '合同文件', type: 'string' },
field('classroomId', '教室', 'number', { required: true }),
field('lesseeOrganizationId', '承租方', 'number'),
field('startDate', '开始日期', 'date', { required: true }),
field('endDate', '结束日期', 'date', { required: true }),
field('dailyRate', '日租金', 'number'),
field('contractPath', '合同文件', 'string'),
],
relations: [
{ entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] },
{ entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] },
relation('classroom', 'classroom_rental', ['rental']),
relation('organization', 'classroom_rental', ['rental']),
relation('schedule', 'class_schedule', ['schedule']),
],
},
}),
];
export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [

View File

@@ -21,7 +21,7 @@ export interface BusinessEntityRelation {
entityKey: string;
via: string;
/** 需要该关系已建立的工作流阶段 key。 */
requiredFor: string[];
requiredFor: readonly string[];
}
export interface BusinessEntity {