feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
This commit is contained in:
@@ -64,6 +64,14 @@ export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
|
||||
}
|
||||
/>
|
||||
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
||||
{!loadingList && !selectionMode && conversationCount === 0 ? (
|
||||
<div className="ai-chat-sidebar__empty">
|
||||
<Typography.Text type="secondary">暂无会话</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
点击「新对话」开始提问
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ai-chat-sidebar__footer">
|
||||
{selectionMode ? (
|
||||
<>
|
||||
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
} from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||
import { aiChatApi, conversationStreamUrl } from './api';
|
||||
import { GongxueAiChatProvider } from './provider';
|
||||
import { welcomeDescription, workflowPromptExamples } from './welcomeCopy';
|
||||
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
|
||||
import type { AiSkill } from './types';
|
||||
import { useAiChatMessageActions } from './useAiChatMessageActions';
|
||||
@@ -51,6 +54,8 @@ interface AiChatDrawerProps {
|
||||
|
||||
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
|
||||
const { modal } = App.useApp();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const permissions = usePermissionStore((state) => state.permissions);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
@@ -524,12 +529,21 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
title="你好,我是恭学 AI 助手"
|
||||
description={
|
||||
lockedSkill?.description ||
|
||||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'
|
||||
welcomeDescription(user?.roles ?? [], permissions)
|
||||
}
|
||||
/>
|
||||
<Prompts
|
||||
title="你可以这样问"
|
||||
items={promptItems}
|
||||
items={[
|
||||
...promptItems,
|
||||
...workflowPromptExamples(user?.roles ?? [], permissions).map(
|
||||
(item, index) => ({
|
||||
key: `workflow-${index}`,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
}),
|
||||
),
|
||||
]}
|
||||
wrap
|
||||
onItemClick={({ data }) => submit(String(data.label || ''))}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
|
||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||
import { LiteMermaid } from './LiteMermaid';
|
||||
import type {
|
||||
@@ -355,26 +356,30 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
/>
|
||||
)}
|
||||
{(message.forms ?? []).map((form) => (
|
||||
<DynamicForm
|
||||
key={form.id}
|
||||
form={form}
|
||||
disabled={streaming}
|
||||
onSubmit={(values) => onSubmitForm?.(form, values)}
|
||||
/>
|
||||
<ArtifactErrorBoundary key={form.id} title="表单">
|
||||
<DynamicForm
|
||||
form={form}
|
||||
disabled={streaming}
|
||||
onSubmit={(values) => onSubmitForm?.(form, values)}
|
||||
/>
|
||||
</ArtifactErrorBoundary>
|
||||
))}
|
||||
{(message.reviews ?? []).map((review: AiReviewSchema) => (
|
||||
<DynamicReview
|
||||
key={review.id}
|
||||
review={review}
|
||||
messageId={typeof message.id === 'number' ? message.id : undefined}
|
||||
disabled={streaming}
|
||||
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
|
||||
onConfirmStep={onConfirmReviewStep}
|
||||
onConfirmGroup={onConfirmReviewGroup}
|
||||
/>
|
||||
<ArtifactErrorBoundary key={review.id} title="导入预览">
|
||||
<DynamicReview
|
||||
review={review}
|
||||
messageId={typeof message.id === 'number' ? message.id : undefined}
|
||||
disabled={streaming}
|
||||
onSubmit={(reviewId) => onSubmitReview?.(reviewId, review.title)}
|
||||
onConfirmStep={onConfirmReviewStep}
|
||||
onConfirmGroup={onConfirmReviewGroup}
|
||||
/>
|
||||
</ArtifactErrorBoundary>
|
||||
))}
|
||||
{(message.charts ?? []).map((chart: AiChartSchema) => (
|
||||
<DynamicChart key={chart.id} chart={chart} />
|
||||
<ArtifactErrorBoundary key={chart.id} title="图表">
|
||||
<DynamicChart chart={chart} />
|
||||
</ArtifactErrorBoundary>
|
||||
))}
|
||||
{message.error && <Alert type="error" showIcon title={message.error} />}
|
||||
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
||||
|
||||
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal file
47
apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Alert } from 'antd';
|
||||
|
||||
interface ArtifactErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
/** 制品标题(用于错误提示文案) */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ArtifactErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI 制品(表单/审查卡/图表)渲染错误兜底:单个制品渲染失败只降级为
|
||||
* 错误占位卡片,不影响同气泡内其他消息与制品。
|
||||
*/
|
||||
export class ArtifactErrorBoundary extends React.Component<
|
||||
ArtifactErrorBoundaryProps,
|
||||
ArtifactErrorBoundaryState
|
||||
> {
|
||||
state: ArtifactErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): ArtifactErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
console.error('[ArtifactErrorBoundary] 制品渲染异常:', error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title={this.props.title ? `${this.props.title}渲染失败` : '此内容渲染失败'}
|
||||
description="请让 AI 重新生成,或刷新后重试。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ArtifactErrorBoundary;
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
import type { EChartsOption } from '../../components/ECharts';
|
||||
import type { AiChartSchema } from './types';
|
||||
import { useXCardSurface } from './useSubmissionState';
|
||||
|
||||
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
|
||||
const ReactECharts = lazy(() => import('../../components/ECharts'));
|
||||
@@ -202,6 +203,27 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
||||
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
|
||||
const [instance, setInstance] = useState<EChartsType | null>(null);
|
||||
if (!chart) return null;
|
||||
// 空数据集:渲染明确占位,而不是一张空白图
|
||||
if (!chart.rows || chart.rows.length === 0) {
|
||||
return (
|
||||
<div className="ai-chat-chart-card">
|
||||
<div className="ai-chat-chart-card__header">
|
||||
<Typography.Text strong>{chart.title}</Typography.Text>
|
||||
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 120,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary">暂无数据</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!instance) return;
|
||||
@@ -254,46 +276,39 @@ export interface DynamicChartProps {
|
||||
* so history replays identically.
|
||||
*/
|
||||
export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
const sid = surfaceId(chart.id);
|
||||
const { commands, pushCommands } = useXCardSurface(sid);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(chart.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
const cmds: XAgentCommand_v0_9[] = [
|
||||
{
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/chart',
|
||||
value: chart,
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ChartPreview',
|
||||
chart: { path: '/chart' },
|
||||
},
|
||||
],
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/chart',
|
||||
value: chart,
|
||||
},
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [chart]);
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ChartPreview',
|
||||
chart: { path: '/chart' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
pushCommands(cmds);
|
||||
}, [chart, pushCommands, sid]);
|
||||
|
||||
return (
|
||||
<div className="ai-chat-chart">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
XCard,
|
||||
registerCatalog,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { AiFormField, AiFormSchema } from './types';
|
||||
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
|
||||
|
||||
const FORM_CATALOG_ID = 'gongxue-form-catalog';
|
||||
|
||||
@@ -179,63 +180,46 @@ export interface DynamicFormProps {
|
||||
* success/failure/loading transitions are pushed as incremental commands.
|
||||
*/
|
||||
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
const sid = surfaceId(form.id);
|
||||
const { submitting, submitted, error, run } = useSubmissionState();
|
||||
const { commands, pushCommands } = useXCardSurface(sid);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(form.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
const cmds: XAgentCommand_v0_9[] = [
|
||||
{
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/form',
|
||||
value: { ...form, submitting, submitted, error },
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'FormPreview',
|
||||
form: { path: '/form' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/form',
|
||||
value: { ...form, submitting, submitted, error },
|
||||
},
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [disabled, error, form, submitted, submitting]);
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'FormPreview',
|
||||
form: { path: '/form' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
pushCommands(cmds);
|
||||
}, [disabled, error, form, pushCommands, sid, submitted, submitting]);
|
||||
|
||||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
void run(async () => {
|
||||
await onSubmit(values);
|
||||
setSubmitted(true);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = (payload: ActionPayload) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TableProps,
|
||||
} from 'antd';
|
||||
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
|
||||
import { useXCardSurface } from './useSubmissionState';
|
||||
import {
|
||||
GROUP_STATUS_LABELS,
|
||||
SECTION_ORDER,
|
||||
@@ -430,9 +431,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
activeTypeRef.current = activeType;
|
||||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
const sid = surfaceId(localReview.id);
|
||||
const { commands, pushCommands } = useXCardSurface(sid);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalReview(review);
|
||||
@@ -455,55 +455,51 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
}, [review]);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(localReview.id);
|
||||
if (idRef.current !== sid) {
|
||||
commandsRef.current = [];
|
||||
idRef.current = sid;
|
||||
}
|
||||
const cmds = commandsRef.current;
|
||||
if (cmds.length === 0) {
|
||||
cmds.push({
|
||||
const cmds: XAgentCommand_v0_9[] = [
|
||||
{
|
||||
version: 'v0.9',
|
||||
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
|
||||
});
|
||||
}
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/review',
|
||||
value: {
|
||||
...localReview,
|
||||
submitting,
|
||||
activeKey,
|
||||
activeType,
|
||||
submittingKey,
|
||||
submittingGroup,
|
||||
error,
|
||||
},
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateDataModel: {
|
||||
surfaceId: sid,
|
||||
path: '/review',
|
||||
value: {
|
||||
...localReview,
|
||||
submitting,
|
||||
activeKey,
|
||||
activeType,
|
||||
submittingKey,
|
||||
submittingGroup,
|
||||
error,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ReviewPreview',
|
||||
review: { path: '/review' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
{
|
||||
version: 'v0.9',
|
||||
updateComponents: {
|
||||
surfaceId: sid,
|
||||
components: [
|
||||
{
|
||||
id: 'root',
|
||||
component: 'ReviewPreview',
|
||||
review: { path: '/review' },
|
||||
disabled: Boolean(disabled),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
];
|
||||
pushCommands(cmds);
|
||||
}, [
|
||||
activeKey,
|
||||
activeType,
|
||||
disabled,
|
||||
error,
|
||||
localReview,
|
||||
pushCommands,
|
||||
sid,
|
||||
submitting,
|
||||
submittingGroup,
|
||||
submittingKey,
|
||||
|
||||
@@ -138,6 +138,16 @@
|
||||
inset: 68px 0 auto;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar__footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
|
||||
@@ -327,8 +327,9 @@ export function useAiChatMessageActions({
|
||||
);
|
||||
|
||||
const submitForm = useCallback(
|
||||
(form: AiFormSchema, values: Record<string, unknown>) => {
|
||||
if (!activeId || isRequesting) return;
|
||||
async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
|
||||
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
|
||||
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
|
||||
requestWithStatus({
|
||||
message: '表单提交',
|
||||
attachmentIds: [],
|
||||
@@ -342,8 +343,9 @@ export function useAiChatMessageActions({
|
||||
);
|
||||
|
||||
const submitReview = useCallback(
|
||||
(reviewId: string, reviewTitle?: string) => {
|
||||
if (!activeId || isRequesting) return;
|
||||
async (reviewId: string, reviewTitle?: string): Promise<void> => {
|
||||
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
|
||||
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
|
||||
requestWithStatus({
|
||||
message: '确认批量导入',
|
||||
attachmentIds: [],
|
||||
|
||||
85
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal file
85
apps/admin/src/components/AiChat/useSubmissionState.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
|
||||
/**
|
||||
* 提交状态管理:收敛 DynamicForm / DynamicReview 中重复的
|
||||
* submitting / submitted / error 状态与「防重复提交 + 失败可重试」逻辑。
|
||||
*
|
||||
* 用法:
|
||||
* const { submitting, submitted, error, run } = useSubmissionState();
|
||||
* const handleSubmit = (values) => run(async () => { await onSubmit(values); });
|
||||
*/
|
||||
export function useSubmissionState() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const run = useCallback(async (task: () => Promise<void> | void) => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await task();
|
||||
setSubmitted(true);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSubmitted(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { submitting, submitted, error, run, reset };
|
||||
}
|
||||
|
||||
/**
|
||||
* A2UI surface 的 XCard commands 增量更新生命周期:
|
||||
* 每个 surface 只创建一次,后续通过 updateDataModel / updateComponents 增量更新。
|
||||
*
|
||||
* 用法:
|
||||
* const { pushCommands } = useXCardSurface(surfaceId);
|
||||
* useEffect(() => {
|
||||
* pushCommands([
|
||||
* { version: 'v0.9', createSurface: { surfaceId, catalogId } },
|
||||
* { version: 'v0.9', updateDataModel: { surfaceId, path: '/x', value } },
|
||||
* { version: 'v0.9', updateComponents: { surfaceId, components } },
|
||||
* ]);
|
||||
* }, [value]);
|
||||
*/
|
||||
export function useXCardSurface(surfaceId: string) {
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||||
const idRef = useRef<string>('');
|
||||
|
||||
const pushCommands = useCallback(
|
||||
(cmds: XAgentCommand_v0_9[]) => {
|
||||
if (cmds.length === 0) return;
|
||||
// 同一 surface 的 createSurface 命令只允许出现一次,自动去重
|
||||
const hasSurface = commandsRef.current.some(
|
||||
(c) => 'createSurface' in c && c.createSurface.surfaceId === surfaceId,
|
||||
);
|
||||
const filtered = hasSurface
|
||||
? cmds.filter((c) => !('createSurface' in c))
|
||||
: cmds;
|
||||
commandsRef.current = [...commandsRef.current, ...filtered];
|
||||
setCommands([...commandsRef.current]);
|
||||
},
|
||||
[surfaceId],
|
||||
);
|
||||
|
||||
const surfaceKey = surfaceId;
|
||||
if (idRef.current !== surfaceKey) {
|
||||
// 组件复用到新 surface 时,清空历史命令重新初始化
|
||||
commandsRef.current = [];
|
||||
idRef.current = surfaceKey;
|
||||
}
|
||||
|
||||
return { commands, pushCommands };
|
||||
}
|
||||
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal file
69
apps/admin/src/components/AiChat/welcomeCopy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getRoleDomains } from '../../auth/menu-policy';
|
||||
|
||||
/**
|
||||
* 按用户角色生成 AI 助手欢迎语,引导用户使用与其岗位匹配的业务闭环。
|
||||
* 优先级:教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管 > 兜底。
|
||||
*/
|
||||
export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {
|
||||
const domains = getRoleDomains(roles, permissions);
|
||||
|
||||
if (domains.has('teacher')) {
|
||||
return '我可以帮你查询今日课程、拉取钉钉考勤、查看排课。课程开始后就能看到打卡结果。';
|
||||
}
|
||||
if (domains.has('accommodation')) {
|
||||
return '我可以帮你完成「宿舍档案 → 学生 → 入住 → 费用 → 账单」的住宿计费闭环,先告诉我你手头有什么数据。';
|
||||
}
|
||||
if (domains.has('academic')) {
|
||||
return '我可以帮你完成「学生档案 → 分班 → 排课 → 考勤」的教学闭环,支持 Excel 批量导入与预览确认。';
|
||||
}
|
||||
if (domains.has('classroom')) {
|
||||
return '我可以帮你管理教室排期与租赁订单,查询占用情况,避免时间冲突。';
|
||||
}
|
||||
if (domains.has('system') || domains.has('super')) {
|
||||
return '我是恭学 AI 助手。我可以查询经营数据、管理业务数据、生成批量导入预览——所有写操作都会先经你确认。';
|
||||
}
|
||||
return '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。';
|
||||
}
|
||||
|
||||
/**
|
||||
* 与角色匹配的业务闭环引导示例(用于 Prompts 建议话术)。
|
||||
* 返回空数组表示当前角色无匹配示例。
|
||||
*/
|
||||
export function workflowPromptExamples(
|
||||
roles: readonly string[],
|
||||
permissions: readonly string[],
|
||||
): { label: string; description: string }[] {
|
||||
const domains = getRoleDomains(roles, permissions);
|
||||
const examples: { label: string; description: string }[] = [];
|
||||
|
||||
if (domains.has('academic')) {
|
||||
examples.push(
|
||||
{ label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },
|
||||
{ label: '查一下这周有哪些班级还没排课', description: '教学闭环' },
|
||||
);
|
||||
}
|
||||
if (domains.has('accommodation')) {
|
||||
examples.push(
|
||||
{ label: '帮我从 Excel 导入学生并安排入住', description: '住宿计费闭环' },
|
||||
{ label: '查一下本月还没生成账单的入住学生', description: '住宿计费闭环' },
|
||||
);
|
||||
}
|
||||
if (domains.has('classroom')) {
|
||||
examples.push(
|
||||
{ label: '查一下这间教室本周的占用情况', description: '教室运营' },
|
||||
);
|
||||
}
|
||||
if (domains.has('teacher')) {
|
||||
examples.push(
|
||||
{ label: '今天我有哪几节课?', description: '今日教学' },
|
||||
);
|
||||
}
|
||||
if (domains.has('system') || domains.has('super')) {
|
||||
examples.push(
|
||||
{ label: '看一下本月的经营概览', description: '数据面板' },
|
||||
{ label: '帮我梳理宿舍计费的完整流程', description: '业务流程' },
|
||||
);
|
||||
}
|
||||
|
||||
return examples;
|
||||
}
|
||||
Reference in New Issue
Block a user