UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import React from 'react';
|
||
import {
|
||
CheckSquareOutlined,
|
||
MenuFoldOutlined,
|
||
MenuUnfoldOutlined,
|
||
PaperClipOutlined,
|
||
PlusOutlined,
|
||
} from '@ant-design/icons';
|
||
import Attachments from '@ant-design/x/es/attachments';
|
||
import Conversations from '@ant-design/x/es/conversations';
|
||
import Sender from '@ant-design/x/es/sender';
|
||
import type { ConversationItemType } from '@ant-design/x';
|
||
import type { AttachmentsProps } from '@ant-design/x/es/attachments';
|
||
import { Button, Dropdown, Spin, Tooltip, Typography } from 'antd';
|
||
import type { MenuProps } from 'antd';
|
||
import type { AiSkill } from './types';
|
||
|
||
export interface AiChatSidebarProps {
|
||
className?: string;
|
||
conversationItems: ConversationItemType[];
|
||
activeConversationKey?: string;
|
||
selectionMode: boolean;
|
||
selectedKeys: string[];
|
||
loadingList: boolean;
|
||
conversationCount: number;
|
||
onActiveChange: (key: string) => void;
|
||
menu?: MenuProps | ((item: ConversationItemType) => MenuProps);
|
||
onStartNewConversation: () => void;
|
||
onSelectAll: () => void;
|
||
onInvertSelection: () => void;
|
||
onDeleteSelected: () => void;
|
||
onExitSelectionMode: () => void;
|
||
onEnterSelectionMode: () => void;
|
||
}
|
||
|
||
export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
|
||
className,
|
||
conversationItems,
|
||
activeConversationKey,
|
||
selectionMode,
|
||
selectedKeys,
|
||
loadingList,
|
||
conversationCount,
|
||
onActiveChange,
|
||
menu,
|
||
onStartNewConversation,
|
||
onSelectAll,
|
||
onInvertSelection,
|
||
onDeleteSelected,
|
||
onExitSelectionMode,
|
||
onEnterSelectionMode,
|
||
}) => {
|
||
return (
|
||
<aside className={className ?? 'ai-chat-sidebar'}>
|
||
<Conversations
|
||
items={conversationItems}
|
||
activeKey={activeConversationKey}
|
||
onActiveChange={onActiveChange}
|
||
menu={selectionMode ? undefined : menu}
|
||
creation={
|
||
selectionMode
|
||
? undefined
|
||
: { label: '新对话', icon: <PlusOutlined />, onClick: onStartNewConversation }
|
||
}
|
||
/>
|
||
{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 ? (
|
||
<>
|
||
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} 已选</span>
|
||
<Button size="small" type="text" onClick={onSelectAll}>
|
||
全选
|
||
</Button>
|
||
<Button size="small" type="text" onClick={onInvertSelection}>
|
||
反选
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
danger
|
||
disabled={selectedKeys.length === 0}
|
||
onClick={onDeleteSelected}
|
||
>
|
||
删除
|
||
</Button>
|
||
<Button size="small" type="text" onClick={onExitSelectionMode}>
|
||
取消
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button
|
||
size="small"
|
||
type="text"
|
||
icon={<CheckSquareOutlined />}
|
||
disabled={conversationCount === 0}
|
||
onClick={onEnterSelectionMode}
|
||
>
|
||
管理
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
export interface AiChatComposerProps {
|
||
conversationTitle: string;
|
||
input: string;
|
||
onChange: (value: string) => void;
|
||
isRequesting: boolean;
|
||
onSubmit: (value: string) => void;
|
||
onCancel: () => void;
|
||
uploadItems: AttachmentsProps['items'];
|
||
onCustomUpload: AttachmentsProps['customRequest'];
|
||
onRemoveAttachment: AttachmentsProps['onRemove'];
|
||
deepThinking: boolean;
|
||
onDeepThinkingChange: (value: boolean) => void;
|
||
lockedSkill?: AiSkill;
|
||
onClearSkill: () => void;
|
||
onToggleSidebar: () => void;
|
||
sidebarOpen: boolean;
|
||
skillMenu: MenuProps;
|
||
}
|
||
|
||
export const AiChatComposer: React.FC<AiChatComposerProps> = ({
|
||
conversationTitle,
|
||
input,
|
||
onChange,
|
||
isRequesting,
|
||
onSubmit,
|
||
onCancel,
|
||
uploadItems,
|
||
onCustomUpload,
|
||
onRemoveAttachment,
|
||
deepThinking,
|
||
onDeepThinkingChange,
|
||
lockedSkill,
|
||
onClearSkill,
|
||
onToggleSidebar,
|
||
sidebarOpen,
|
||
skillMenu,
|
||
}) => {
|
||
return (
|
||
<>
|
||
<div className="ai-chat-toolbar">
|
||
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
|
||
<Button
|
||
type="text"
|
||
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
|
||
onClick={onToggleSidebar}
|
||
/>
|
||
</Tooltip>
|
||
<Typography.Text ellipsis>{conversationTitle}</Typography.Text>
|
||
<Dropdown menu={skillMenu} trigger={['click']}>
|
||
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
|
||
</Dropdown>
|
||
</div>
|
||
<div className="ai-chat-composer">
|
||
<Sender
|
||
value={input}
|
||
onChange={onChange}
|
||
loading={isRequesting}
|
||
onSubmit={onSubmit}
|
||
onCancel={onCancel}
|
||
onKeyDown={(e) => {
|
||
// 中文输入法合成中的回车(确认候选词)不应触发发送。
|
||
// 浏览器在 compositionend 后仍会派发 Enter keydown,
|
||
// 此时 Sender 内部的 composition 标记已失效,需用
|
||
// KeyboardEvent.isComposing / keyCode 229 兜底。
|
||
if (e.nativeEvent.isComposing || e.keyCode === 229) {
|
||
return false;
|
||
}
|
||
return undefined;
|
||
}}
|
||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||
placeholder="询问学生、考勤、宿舍或账单数据"
|
||
skill={
|
||
lockedSkill
|
||
? {
|
||
title: lockedSkill.name,
|
||
value: lockedSkill.key,
|
||
closable: { onClose: onClearSkill },
|
||
}
|
||
: undefined
|
||
}
|
||
header={
|
||
(uploadItems ?? []).length > 0 && (
|
||
<div className="ai-chat-sender-header">
|
||
<Attachments
|
||
items={uploadItems}
|
||
customRequest={onCustomUpload}
|
||
onRemove={onRemoveAttachment}
|
||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||
multiple
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
footer={
|
||
<div className="ai-chat-sender-footer">
|
||
<Tooltip title="添加附件">
|
||
<Attachments
|
||
items={[]}
|
||
customRequest={onCustomUpload}
|
||
onRemove={onRemoveAttachment}
|
||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||
multiple
|
||
placeholder={{
|
||
title: '添加附件',
|
||
description: '图片、PDF、Word、Excel,单个不超过 10MB',
|
||
}}
|
||
>
|
||
<Button type="text" icon={<PaperClipOutlined />} aria-label="添加附件" />
|
||
</Attachments>
|
||
</Tooltip>
|
||
<Sender.Switch
|
||
checkedChildren="深度思考"
|
||
unCheckedChildren="普通"
|
||
value={deepThinking}
|
||
onChange={onDeepThinkingChange}
|
||
disabled={isRequesting}
|
||
/>
|
||
</div>
|
||
}
|
||
/>
|
||
<Typography.Text type="secondary" className="ai-chat-disclaimer">
|
||
AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准
|
||
</Typography.Text>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|