Files
gongxue-base/apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx
wangziqi 67435e46ca 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 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

241 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
};