Commit a503cdbd authored by Andy-bubu's avatar Andy-bubu

feat: migrate UI to Xinyuan HeroUI design system

parent ba702ba8
......@@ -10,6 +10,7 @@
/.umi-test
/dist
/.mfsu
/.playwright-cli/
.swc
.idea/*
.git/*
......
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
overrides: [
{
files: ['src/design-system/shell.css'],
rules: {
'no-duplicate-selectors': null,
'selector-class-pattern': null,
'value-keyword-case': null,
},
},
{
files: ['src/tailwind.css'],
rules: {
'at-rule-no-unknown': [true, { ignoreAtRules: ['theme'] }],
'no-duplicate-selectors': null,
'property-no-vendor-prefix': null,
'selector-class-pattern': null,
},
},
],
};
import { defineConfig } from '@umijs/max';
import path from 'path';
function resolvePackageRoot(packageName: string) {
return path.dirname(require.resolve(`${packageName}/package.json`));
}
const APP_BASE_PATH = '/ai_chat/';
export default defineConfig({
base: '/ai_chat/',
publicPath: '/ai_chat/',
antd: {},
base: APP_BASE_PATH,
publicPath: APP_BASE_PATH,
links: [
{
rel: 'stylesheet',
href: `${APP_BASE_PATH}fonts/inter.css`,
},
],
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
},
access: {},
model: {},
initialState: {},
request: {},
mfsu: false,
esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
reactQuery: {
devtool: false,
},
......
# AI 应用 UI 架构
## 分层
```text
Umi route /ai_chat/
-> root theme and CSS imports
-> HeroUI Pro AppShell
-> Sidebar: identity, new session, conversations, app switcher, user menu
-> Navbar: Sidebar.Trigger, route title, page actions, theme action
-> page canvas: AI workspace
-> conversation/content surface
-> fixed PromptInput composer
-> PromptInput and attachment workflow
-> root-level HeroUI overlays
-> login
-> application switcher
-> conversation confirmations
-> audit knowledge and prompt dialogs
```
## 所有权规则
- 页面/controller 保留业务 state、API 参数、回调、格式转换、请求时机和权限判断。
- HeroUI/HeroUI Pro 组件拥有可见控件、ARIA、焦点、键盘交互、Portal 和视觉状态。
- 标准弹窗通过共享 `rc-dialog-panel--{size}` 尺寸梯度约束宽度;内容型配置/导入弹窗使用 `4xl = 1088px`,桌面保留 40px 安全边距,手机保留 16px 安全边距。
- 页面级短命令通过 AppShell 的 Navbar actions 插槽渲染在主题按钮左侧;窄屏使用 HeroUI Dropdown,页面正文不重复同一状态摘要。
- AI 工作区由固定视口高度承载;`ChatConversation` 是唯一纵向滚动区域,PromptInput 作为 flex 尾部固定在底部。PromptInput 使用 HeroUI Pro 官方 `stacked`/`lg` 组合,保持附件、停止生成、发送和自动扩展行为。
- 共享组件只能是业务无关的 HeroUI-native composition,不得复刻 Ant/旧组件 props、事件或 DOM。
- Ant Design Icons 可作为纯图标目录保留;不得通过它包装 UI primitive。
- 服务层、审计引擎、Excel 解析/导出和 tracking 不因 UI 迁移而重写。
## 依赖来源
- HeroUI Pro 由目标仓库内官方授权 setup 流程独立安装。
- `package.json` 使用正式版本号,不使用 `file:`、绝对路径、相邻项目 `node_modules`、软链接或 vendor 包。
- 项目只保留 `pnpm-lock.yaml`。
- 最终交付前必须验证一个兼容的 React、HeroUI 与 React Aria runtime graph。
实际安装解析为 `@heroui-pro/react@1.0.0-beta.8`、`@heroui/react@3.2.4`、`@heroui/styles@3.2.4`、React/ReactDOM `19.2.8`。官方安装壳的锁文件元数据不包含完整 Pro 包的 peer 声明,因此 `pnpm-workspace.yaml` 使用 `packageExtensions` 补充包内实际声明的 React/ReactDOM `>=19.0.0`,使 Pro 运行链固定到根 React 19。Umi 工具链仍保留自身的 React 18 传递副本;生产构建成功,运行产物与浏览器未出现重复 React、React Aria Context、slot 或 provider 警告。
## 字体加载边界
- `src/tailwind.css` 只负责 Tailwind、HeroUI、HeroUI Pro、鑫元主题与共享样式,不得导入 `@fontsource/inter/*` 或声明 `@font-face`。
- Inter 400/500/600 的字体声明与 WOFF2 由 `public/fonts/` 持有,并通过 `.umirc.ts` 的稳定 HTML stylesheet link 加载。
- 该 link 不属于 Umi 的 `umi.css` HMR 生命周期;更新页面 CSS 时允许替换 `umi.css`,不得替换或移除 `fonts/inter.css`。
- `--font-inter`、`--font-sans` 与页面字体栈仍由鑫元主题/Tokens 消费,不改变组件字体契约。
## 迁移映射
| 旧边界 | 新边界 |
| --- | --- |
| Ant ConfigProvider/antd-style 全局主题 | HeroUI CSS + 权威 theme.css + tokens.css |
| Ant Modal | HeroUI Modal/AlertDialog compound API |
| Ant Button/Input/Form/Dropdown/Tooltip/Drawer | 对应 HeroUI primitive/compound API |
| Ant X Conversations/Bubble/Sender/Attachments/Prompts | HeroUI Pro AI 与导航组件 |
| Ant Upload | HeroUI Pro DropZone |
| Ant Table | HeroUI Pro DataGrid |
| Ant Empty/Spin/Skeleton/Tag | HeroUI/Pro EmptyState、Spinner、Skeleton、Chip |
## 业务保护检查点
- 登录仍调用现有 `getCaptcha` 与 `login`,保留 token、错误和刷新逻辑。
- 登录成功仍写入原 `current_user` 结构并触发 Home 的现有刷新路径。
- 退出仍删除 `current_user`,不更改路由 base。
- 会话 CRUD、置顶顺序、当前会话选择和请求参数不变。
- AI 请求 messages/context、知识库检索、MCP、附件和导出协议不变。
- 应用列表仍来自现有接口并沿用当前应用合并与跳转规则。
## 不适用范围
本应用当前没有真实 KPI、图表、分页列表、日期筛选或页面级 Tabs。架构不添加模拟业务数据;未来出现真实需求时,必须按 HeroUI Pro KPI/Chart/DataGrid/Pagination 和 HeroUI Tabs/DatePicker 契约直接实现。
## 验收边界
业务保护优先于制造测试状态。当前账号没有真实会话,且没有提供审计知识库 Excel,因此不会创建测试会话或伪造风险点数据。会话行操作和解析后 DataGrid 仍需在真实数据可用时完成最终操作验收;这两个项目在检查清单中保持进行中,不作为“迁移完成”宣称。
# AI 应用 UI 迁移检查清单
状态:`[x]` 已完成,`[~]` 进行中,`[ ]` 未完成,`[-]` 当前业务不存在。
## 接入与审计
- [x] 拉取最新 `main` 并创建专用迁移分支
- [x] 在目标根目录完成官方 HeroUI Pro 授权安装
- [x] 验证 Pro 版本、解析路径、CSS 与指定子路径
- [x] 启动未修改目标应用并记录 1440x900 登录基线
- [x] 同时启动“问题整改”并记录同视口登录基线
- [x] 审计唯一活动路由、登录、退出、会话、应用切换、Portal 与业务服务
- [x] 建立四份强制文档
## 主题与架构
- [x] 引入权威 `theme.css` 原始副本
- [x] 引入批准的 `tokens.css` 与 Shell 布局 Tokens
- [x] 建立 Tailwind -> HeroUI -> Pro -> theme -> tokens 顺序
- [x] 根级浅/深色与 Portal 主题继承
- [x] 活动页面 Ant Design/Ant Design X/antd-style 视觉引用归零
- [x] 生产解析固定为 React 19;浏览器无重复 Context/slot/provider 警告
- [x] Inter 从 Umi 热更新 CSS 移出,改由稳定 HTML link 加载本地 400/500/600 WOFF2
## 登录与 Shell
- [x] 登录页居中 Card、Logo、标题、宽度和 24px 字段节奏
- [x] 账号、密码、验证码 40px 等高与 filled/autofill 状态
- [x] HeroUI Pro Sidebar 240px 与真 offcanvas
- [x] HeroUI Pro Navbar、Sidebar.Trigger 与 48/32/16px 对称边距
- [x] 主题切换及持久化
- [x] Sidebar 用户 Dropdown 与真实退出登录
- [x] 应用切换 HeroUI Modal、两列应用 Card 和 identity well
- [x] 移动端 Sidebar.Mobile
## AI 与业务模块
- [~] 会话列表、新建、切换、重命名、置顶和删除确认:实现已迁移;当前账号无真实会话,行菜单待真实记录复验
- [x] 主对话列表、PromptInput、附件和发送/停止状态
- [x] 消息区独立滚动、PromptInput 底部固定,并提升为 HeroUI Pro stacked/lg 高度布局
- [x] 空会话使用 HeroUI Pro PromptSuggestion 原生分组与 Card 网格;单项满行、双项两列、移动单列,PromptInput 使用 compact 布局
- [x] 审计知识库操作迁入 Navbar 主题按钮左侧,移动端使用 HeroUI Dropdown;删除 PromptInput 上方重复的文件与数量摘要
- [~] 审计知识库状态、配置、导入、结果与导出:DropZone/Modal 已验;无授权 Excel,DataGrid 真实行待复验
- [x] 发票助手上传、步骤、确认和下载的组件边界迁移
- [x] 所有 Modal/AlertDialog/Dropdown/Tooltip/DropZone 使用 HeroUI 根
- [x] 标准 Modal 使用共享尺寸梯度;提示词与 Excel 配置弹窗限制为 `4xl = 1088px`,手机宽度为视口减 32px,移动 Footer 操作纵向满宽排列
- [-] 页面级 Tabs:当前活动路由不存在
- [-] KPI:当前无真实 KPI 数据
- [-] PieChart/BarChart/ComposedChart:当前无真实图表数据
- [-] 业务分页:当前无分页列表
## 浏览器验收
- [x] 1440x900 浅色与深色
- [x] 1280x720 浅色与深色
- [x] 768x1024 浅色与深色
- [x] 390x844 浅色与深色
- [x] 登录、filled/autofill、失败刷新验证码
- [x] Sidebar 开关、移动 offcanvas、用户菜单、退出
- [x] 应用切换打开态、图片/失败/兜底状态
- [~] 会话菜单、删除确认、业务配置弹窗和上传打开态:配置弹窗/上传通过;空会话导致行菜单待真实记录复验
- [x] Tooltip、焦点、Escape、Console、页面溢出
- [x] 长消息滚动时输入框坐标稳定;390×844 移动端无页面横向溢出
- [x] 连续 3 次 CSS HMR 中字体 link 不变、字体资源无二次请求、35 次拉丁字符宽度采样无变化
## 最终门槛
- [x] `npm run build`
- [x] `git diff --check`
- [x] 鑫元 `audit_project.sh` 已执行;记录脚本的 PromptInput/CommonJS 子路径误报
- [x] 旧组件引用扫描为零
- [x] 页面与共享 JSX 私有视觉 utility 扫描为零
- [x] `file:`、绝对本机路径、相邻 `node_modules` 依赖扫描为零
- [x] HeroUI/React Aria 生产解析检查通过
- [x] HeroUI Pro 指定子路径 ESM 导入检查通过
- [x] 授权值扫描为零
- [x] 未提交、未推送、未合并
# AI 应用鑫元设计系统
## 权威来源
本项目以已确认的“问题整改”应用为唯一可执行视觉与交互基准,并使用鑫元 Skill 的 HeroUI 权威主题和共享 Tokens。页面不得自行近似组件外观。
全局 CSS 顺序固定为:
```css
@import 'tailwindcss';
@import '@heroui/styles';
@import '@heroui-pro/react/css';
@import './design-system/theme.css';
@import './design-system/tokens.css';
```
`src/design-system/theme.css` 必须与权威资产逐字节一致。`tokens.css` 仅新增或映射 `--rc-*`,不得覆盖 HeroUI 核心变量。
Inter 字体文件独立存放于 `public/fonts/`,并由 `.umirc.ts` 注入稳定的 `/ai_chat/fonts/inter.css` link。禁止在 `src/tailwind.css` 或其它参与 Umi HMR 的样式入口中导入 `@fontsource/inter/*`,避免热替换 `umi.css` 时撤销字体注册并触发拉丁字符回退。
## 组件边界
- 基础组件直接从 `@heroui/react` 导入。
- Sidebar、Navbar、AI、DataGrid、DropZone、ActionBar、EmptyState 等从 `@heroui-pro/react` 或正式子路径直接导入。
- 使用 compound API 和 `onPress`;不建立兼容旧 props 的包装层。
- Ant Design Icons 是唯一图标来源;Ant Design、Ant Design Pro、Ant Design X 和 antd-style 不再拥有活动页面视觉组件。
- `className` 只承担 flex、grid、宽度、间距、对齐、滚动和响应式布局。
- Button、Input、Tabs、Card、Modal、DataGrid、Pagination、Tooltip、Dropdown 的背景、颜色、边框、圆角、阴影和状态由 HeroUI 与权威主题拥有。
## Shell 与认证
- 应用统一展示名:`AI 应用`。
- 登录为居中认证 Card,结构、宽度、字段高度、验证码网格和 24px 节奏与“问题整改”一致。
- Sidebar 宽 240px,使用 HeroUI Pro `collapsible="offcanvas"`;收起后占用宽度为零,不保留 icon rail。
- Navbar 高 64px,使用 HeroUI Pro `Sidebar.Trigger` 默认图形。
- Navbar 与页面内容共享对称 gutter:桌面 48px、平板 32px、手机 16px。
- Sidebar 用户整行通过 `Dropdown.Trigger` 打开菜单,`退出登录` 调用现有清理流程。
- 应用切换为 HeroUI Modal,46rem/42rem 上限,一列手机/两列 `sm` 以上,应用 identity well 为 36px `Surface tertiary + Avatar soft`。
## Surface 与主题
- 页面画布 L0:`background`。
- 第一内容层 L1:`surface/default`。
- 嵌套层 L2:`surface-secondary`,Surface/Card/Modal/Overlay 内控件使用 `secondary`。
- 仅真实第三层静态内容使用 L3 `surface-tertiary`。
- Modal、Dropdown、Popover、Tooltip 等浮层使用独立 `overlay`,不当作第四层 Surface。
- 根节点拥有 `.light/.dark` 与 `data-theme`,Portal 必须继承;主题持久化键为 `xinyuan_color_theme`。
## AI 工作区
- 会话导航位于标准 Sidebar 内容区,不作为独立浮动 Card。
- 新建会话是清晰命令;会话项和用户菜单使用 HeroUI 原生交互,不自制 Hover/Selected 外观。
- 对话内容使用 HeroUI Pro AI 组件;附件使用 HeroUI Pro DropZone/ChatAttachment;输入使用 PromptInput。
- 审计知识库配置、提示词、导入、结果和发票流程沿用真实业务数据,只迁移视觉组件边界。
- 不存在真实 KPI、图表、Tabs 或分页业务时不制造示例数据。
## 响应式与验收
- 必测 1440x900、1280x720、768x1024、390x844。
- 每个视口覆盖浅色和深色、Sidebar、Navbar、登录、退出、应用切换、所有打开态菜单/弹窗/上传/Tooltip。
- 不允许页面级横向溢出、文本遮挡、Portal 白色孤岛、控制台错误或 React Aria slot/provider 警告。
## 本次落地结果
- 权威 `theme.css` SHA-256 为 `a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`,与 Skill 资产逐字节一致。
- Umi 的未使用 `antd` 插件已移除,构建产物不再注入 Ant reset;暗色标题计算为 HeroUI 前景色。
- 页面、业务模块和共享 JSX 的私有视觉 utility 扫描为零;共享外观仅位于权威主题、批准 Tokens 和共享 Shell 设计系统层。
- Ant Design、Ant Design Pro、Ant Design X、Ant X Markdown、antd-style、Headless UI 和 Catalyst UI 活动引用为零。
- 当前业务没有真实 Tabs、筛选、KPI、图表或分页;不得为补齐目录而新增非业务模块。
# AI 应用鑫元 UI 审计
## 审计基线
- 审计日期:2026-08-21
- 迁移分支:`codex/ui-ux-xinyuan-migration`
- 视觉与交互唯一基准:已确认的“问题整改”应用
- 技术栈:Umi 4、React、JavaScript/JSX、pnpm
- 应用 base/publicPath:`/ai_chat/`
- 未修改 UI 启动地址:`http://localhost:8001/ai_chat/`
- 基准登录页启动地址:`http://localhost:8000/problem_rectification_new/login`
## HeroUI 安装门槛
- 实际执行目录:`/Users/liuchenlong/Desktop/job/ai-chat-xy`。
- 已成功执行用户指定的 `pnpm dlx hpsetup@latest <授权值已隐去>`;退出状态为 0。授权值未写入源码、文档、环境文件或 Git。
- `@heroui-pro/react` 解析版本为 `1.0.0-beta.8`。
- `@heroui-pro/react` 实际解析路径为项目内 `node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8_react-dom@19.2.8_react@19.2.8__react@19.2.8/node_modules/@heroui-pro/react`。
- `@heroui/react` 与 `@heroui/styles` 解析版本为 `3.2.4`。
- `sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state`、`chat-conversation`、`prompt-input` 子路径均解析到项目自己的 pnpm 虚拟存储并可通过 ESM 实际导入。
- `css` 子路径解析到 `dist/css/index.css`;Node 不直接执行 CSS,文件和 package export 均已验证存在。
- 安装修改了 `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`,未生成第二种锁文件。
- 官方安装壳未在锁文件暴露完整包的 React peer;项目通过 pnpm `packageExtensions` 声明包内真实的 React/ReactDOM `>=19.0.0` peer,避免 Pro 子路径误解析到 Umi 工具链的 React 18 传递副本。
- 授权值源码扫描结果为零。
## 最终浏览器验收
目标生产构建运行于 `http://localhost:8020/ai_chat/`,并与运行中的“问题整改”在相同视口逐项对照。临时浏览器验收截图已在检查完成后清理,实测结果保留在下方记录中。
| 视口 | 浅色 | 深色 | Shell 结果 |
| --- | --- | --- | --- |
| 1440x900 | 通过 | 通过 | Sidebar 240px、Navbar 64px、桌面 gutter 对齐、无横向溢出 |
| 1280x720 | 通过 | 通过 | Sidebar 240px、Navbar 64px、无横向溢出 |
| 768x1024 | 通过 | 通过 | Sidebar 真 offcanvas、Navbar 64px、无横向溢出 |
| 390x844 | 通过 | 通过 | 312px 移动抽屉、16px 页面边距、单列内容、无横向溢出 |
- 登录:目标与基准桌面均为约 416px Card;36px Logo、40px 字段、24px 字段节奏、验证码与 filled 状态通过。手机均为 326px Card。
- 退出:真实点击用户 Dropdown 的“退出登录”后,`current_user` 为 `null` 并回到登录界面。
- 主题:切换后 `xinyuan_color_theme=dark`,刷新后仍为 dark;Portal 弹窗计算为暗色主题背景与前景。
- Sidebar:桌面折叠后内容可见宽度为 0,主区域回到 x=0;移动打开宽 312px,Escape 关闭。
- 应用切换:实际 `/api/application/info` 返回 3 个可访问应用;桌面 736px、两列、仅 1 个当前状态;手机 358px、单列;Escape 关闭。
- 弹窗:审计提示词和 Excel 配置均使用共享 `4xl` HeroUI Modal 尺寸,最大宽度 1088px;手机宽度为视口减 32px,Footer 操作纵向满宽排列。DropZone 打开态、浅/深色和 Escape 已验证。
- Modal 尺寸实测:目标在 1440×900 为 `x=176 / width=1088`,在 3072×1200 仍为 `width=1088`,在 390×844 为 `x=16 / width=358`;“问题整改”基准在 1280×720 为 `width=1088`,在 390×844 同为 `x=16 / width=358`。目标弹窗 `scrollWidth === clientWidth`,页面无横向溢出,控制台 0 error / 0 warning。
- 控制台:生产验收会话为 0 error / 0 warning;八种主题/视口组合均无页面横向溢出。
- 审计知识库操作:`回答规则`、`重新加载`、`更换 Excel` 位于 Navbar 主题按钮左侧;无文件时按业务条件显示 `配置 Excel`。移动端收敛为原生 HeroUI Dropdown。输入区不再重复展示知识库名称、文件名和有效风险点数量。
- 聊天工作区:消息区设置为唯一纵向滚动容器,PromptInput 固定在工作区底部;长消息滚动前后输入框坐标保持 `y=560 / bottom=688`(1280×720)不变。PromptInput 使用官方 stacked/lg 布局,实际高度 128px、正文输入区 72px。
## Inter 字体 HMR 稳定性
- 根因:原 `src/tailwind.css` 直接导入 `@fontsource/inter/400.css`、`500.css` 和 `600.css`,Umi 每次 CSS HMR 替换 `umi.css` 时会连同其中的 `@font-face` 一起撤销并重新注册,导致英文、数字和符号在 Inter 与回退字体之间跳动。
- 修复:字体声明和 400/500/600 WOFF2 固定放在 `public/fonts/`,由 `.umirc.ts` 向 HTML 注入 `/ai_chat/fonts/inter.css`;热更新入口不再导入字体 CSS。
- 浏览器实测:在干净会话中连续触发 3 次 `src/tailwind.css` HMR,DOM 仅新增/移除 3 组 `umi.css?...` link;`fonts/inter.css` 保持同一个已连接节点,字体 CSS 与 3 个 WOFF2 均无二次网络请求。
- 400/500/600 三种字重的 `document.fonts.check()` 全部为 `true`;英文、数字和符号探针在 35 次采样中只有一个宽度值 `718.296875px`,无字体回退引起的布局变化。
- 浏览器会在样式重算时发出 `FontFaceSet` loading/loadingdone 事件,但事件期间无字体资源请求、无 link 替换、无探针宽度变化;这属于字体匹配重新评估,不是字体卸载或回退。
## 数据依赖验收限制
- 当前账号的会话查询返回空数组,因此未创建测试会话,也未伪造业务数据;会话行菜单、重命名和删除确认尚无真实记录可操作。
- 未提供真实审计知识库 Excel,因此未上传测试文件;DropZone 打开态已通过,但解析后 DataGrid 的真实行、排序和固定列仍需用授权业务文件复验。
- 当前活动路由不存在 Tabs、筛选区、KPI、图表、分页或图表 Tooltip,这些项目为不适用,不制造演示数据。
## 路由与业务状态
| 范围 | 现状 | 保护要求 |
| --- | --- | --- |
| `/ai_chat/` | 唯一活动路由,渲染 `Home` | 路由、base、publicPath 不变 |
| 登录 | 页面打开后以 Modal 阻断;调用验证码、登录和用户信息接口 | 接口、字段、验证码 token、失败刷新、成功回调和 localStorage 协议不变 |
| 会话 | 查询、新建、切换、重命名、置顶、删除 | 请求时机、参数、活动会话和删除确认行为不变 |
| AI 对话 | 普通问答、知识库问答、审计检查清单、附件和工具调用 | 消息结构、请求上下文、流式行为、错误回退不变 |
| 发票助手 | 由应用配置启用的对话分支 | 上传、识别、确认、下载和状态流不变 |
| 审计知识库 | Excel 导入、提示词配置、风险点结果和导出 | Excel 解析、保存、查询和导出逻辑不变 |
| 应用切换 | 调用系统信息与应用列表接口后跳转 | 数据解包、当前应用兜底、权限列表和跳转回调不变 |
| 退出登录 | 删除 `current_user` 并重新显示登录 | 真实清理行为不变,迁移到 Sidebar 用户菜单入口 |
`Access` 页面和已注释 Chat 路由当前不可达,记录为非活动代码;迁移完成前仍执行旧组件引用扫描,避免活动入口间接引用旧视觉组件。
## UI 与样式库存
| 文件/区域 | 当前 UI 所有者 | 主要问题 | 迁移目标 |
| --- | --- | --- | --- |
| `src/layouts/index.jsx` | Ant ConfigProvider + antd-style | 深色蓝青全局主题、渐变、私有圆角和阴影 | HeroUI CSS 顺序、权威 theme、鑫元 Tokens、根级浅/深色 |
| `src/pages/Home/index.jsx` | Ant Button/Drawer/Dropdown/Modal/Tooltip + Ant X Conversations | 316px 浮动玻璃 Sidebar、icon rail 折叠、无基准 Navbar、页面私有状态 | HeroUI Pro Sidebar/Navbar 真 offcanvas、用户菜单、应用切换、对称 gutter |
| `src/components/LoginModal` | ProForm + Ant Modal | 920px 营销分栏、私有渐变、字段 48px、非基准交互 | 与“问题整改”一致的居中 HeroUI Card 登录页 |
| `src/components/ApplicationSwitcherDialog` | Ant Modal/Alert/Empty/Skeleton | 统计、分组、三列倾向、装饰内容和私有外观 | HeroUI Modal、固定 46rem/42rem、两列应用 Card、单一当前状态 |
| `src/pages/Chat/index.jsx` | Ant X Bubble/Sender/Prompts/Attachments + Ant Form/Modal | 大量 CSS-in-JS、蓝青渐变、私有弹窗和上传 | HeroUI Pro AI 组件、HeroUI 表单/Modal、HeroUI Pro DropZone |
| `src/pages/Chat/invoice.jsx` | Ant X + Ant Steps/Tag/Button | 私有步骤、状态和视觉主题 | HeroUI/Pro 原生反馈、状态、附件和会话结构 |
| `src/features/audit/components.jsx` | Ant Table/Collapse/Form/Upload/Modal | 私有 Table、Upload、Modal、Tag 和结果样式 | HeroUI Pro DataGrid/DropZone,HeroUI Disclosure/Modal/Chip |
## 控件与 Portal 审计
- 登录 Modal、配置 Modal、Excel 导入 Modal、删除确认 Modal 和应用切换 Modal 均为 Ant Portal,必须迁移到 HeroUI 根并继承根主题。
- 用户菜单和会话操作菜单当前为 Ant Dropdown,必须迁移到 HeroUI Dropdown compound API。
- 移动侧栏当前为 Ant Drawer,必须由 HeroUI Pro Sidebar.Mobile 接管。
- 文件上传当前包含 Ant Upload/Attachments;活动页面不得保留可见原生 file 控件,最终只允许 DropZone 内部隐藏 input。
- 审计结果包含 Ant Table;若业务数据表仍活动,必须迁移到 HeroUI Pro DataGrid 原生选择/排序/固定列/EmptyState/Pagination 契约。
- 当前活动范围未发现 KPI 或 PieChart/BarChart/ComposedChart 业务数据。禁止为了验收伪造 KPI/图表;若后续配置启用真实数据,应直接使用 HeroUI Pro compound API。
- 当前无页面级 Tabs、DatePicker 或可见原生 select/date 控件;不凭空增加。
## 原始浏览器基线
- 1440x900 目标:深色玻璃聊天工作台,左侧 316px,登录为 920x564 营销式分栏 Modal。
- 1440x900 基准:浅色页面画布,416x457 居中登录 Card,36px Logo,账号/密码/验证码 40px 等高,字段组节奏 24px。
- 目标原始控制台包含开发环境日志;迁移验收将重新记录 error/warning,不能沿用此基线作为通过证据。
- 未提供登录凭据;登录后页面将通过浏览器级 API fixture 验证代表性状态,fixture 不写入应用源码且不作为业务数据。
## 初始风险
- 官方安装将 React 升至 19.2.8,而 Umi 的部分开发依赖仍声明/携带 React 18 运行图,需要在最终依赖树与浏览器 Context 检查中确认运行时唯一性。
- 活动 UI 文件合计超过 7,000 行,大量视觉与业务逻辑同文件;迁移必须逐个保留回调,不能用整页演示代码替换。
- 应用不是典型表格后台。Shell、登录、应用切换必须精确复刻基准;不存在的 Tabs、KPI、图表和列表业务不得伪造。
......@@ -11,20 +11,48 @@
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@ant-design/x": "^1.6.0",
"@ant-design/x-markdown": "2.0.0-alpha.5",
"@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "^3.2.4",
"@heroui/styles": "^3.2.4",
"@modelcontextprotocol/sdk": "^1.17.5",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.3.3",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.4.12",
"antd": "^5.4.0",
"antd-style": "^3.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"lodash": "^4.17.21",
"maplibre-gl": "^6.4.1",
"marked": "^16.2.1",
"marked-katex-extension": "^5.1.5",
"motion": "^13.1.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -3,3 +3,11 @@ allowBuilds:
core-js-pure: false
es5-ext: false
esbuild: false
'@heroui-pro/react': true
'@zowe/secrets-for-zowe-sdk': true
packageExtensions:
'@heroui-pro/react@1.0.0-beta.8':
peerDependencies:
react: '>=19.0.0'
react-dom: '>=19.0.0'
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
@font-face {
font-family: Inter;
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('./inter-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: Inter;
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('./inter-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: Inter;
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('./inter-latin-600-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
import '@/tailwind.css';
import { defineApp, matchRoutes } from 'umi';
import { THEME_STORAGE_KEY } from './config/application';
import { createTracking } from './services/DataService';
import {
collectAppInfo,
......@@ -6,6 +8,17 @@ import {
collectUserInfo,
} from './utils/trackingHelper';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const activeTheme = storedTheme === 'dark' ? 'dark' : 'light';
const root = document.documentElement;
root.classList.toggle('dark', activeTheme === 'dark');
root.classList.toggle('light', activeTheme === 'light');
root.dataset.theme = activeTheme;
root.style.colorScheme = activeTheme;
}
export const request = {
prefix: '/api',
timeout: 30000,
......
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CommentOutlined from '@ant-design/icons/CommentOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import PushpinOutlined from '@ant-design/icons/PushpinOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon
aria-hidden="true"
{...props}
style={size === undefined ? style : { fontSize: size, ...style }}
/>
);
};
}
export const AppGrid = withSize(AppstoreOutlined);
export const Close = withSize(CloseOutlined);
export const Comment = withSize(CommentOutlined);
export const Delete = withSize(DeleteOutlined);
export const Edit = withSize(EditOutlined);
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined);
export const Plus = withSize(PlusOutlined);
export const Pin = withSize(PushpinOutlined);
export const Sun = withSize(SunOutlined);
export function SidebarPanel({ size = 16, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
className={['anticon', props.className].filter(Boolean).join(' ')}
style={{ fontSize: size, ...style }}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
viewBox="0 0 16 16"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
import { THEME_STORAGE_KEY } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { createContext, useContext, useLayoutEffect, useState } from 'react';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
const NavbarActionsContext = createContext(null);
export function useNavbarActions(actions) {
const setNavbarActions = useContext(NavbarActionsContext);
useLayoutEffect(() => {
if (!setNavbarActions) return undefined;
setNavbarActions(actions || null);
return () => {
setNavbarActions((current) => (current === actions ? null : current));
};
}, [actions, setNavbarActions]);
}
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
open,
onOpenChange,
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
const [navbarActions, setNavbarActions] = useState(null);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<NavbarActionsContext.Provider value={setNavbarActions}>
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar theme={theme} {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
actions={navbarActions}
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
</NavbarActionsContext.Provider>
);
}
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { getApplications } from '@/services/DataService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
AppstoreOutlined,
ExclamationCircleOutlined,
ExportOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { Alert, Empty, Modal, Skeleton } from 'antd';
import { createStyles } from 'antd-style';
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { getApplications, getSystemInfo } from '../../services/DataService';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
return application?.name ? `/${application.name}` : '#';
}
function normalizeApplication(application, fallback = {}) {
......@@ -52,56 +36,15 @@ function normalizeApplication(application, fallback = {}) {
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function extractApplications(response) {
if (Array.isArray(response)) {
return response;
}
if (Array.isArray(response?.records)) {
return response.records;
}
if (Array.isArray(response?.applications)) {
return response.applications;
}
if (Array.isArray(response?.data)) {
return response.data;
}
if (Array.isArray(response)) return response;
if (Array.isArray(response?.records)) return response.records;
if (Array.isArray(response?.applications)) return response.applications;
if (Array.isArray(response?.data)) return response.data;
return [];
}
......@@ -109,110 +52,35 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
const mergedMap = new Map(
fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
.filter((application) => application.name)
.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
if (application?.name && !mergedMap.has(application.name)) {
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
}
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
return orderDifference || left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
if (typeof window === 'undefined') return false;
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
......@@ -221,526 +89,96 @@ function isCurrentApplication(application) {
);
}
const useStyles = createStyles(({ css, responsive }) => ({
modal: css`
.ant-modal-content {
overflow: hidden;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.72);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.22);
padding: 0;
}
.ant-modal-body {
padding: 0;
}
`,
header: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid rgba(228, 228, 231, 0.82);
${responsive.xs} {
flex-direction: column;
padding: 18px;
}
`,
eyebrow: css`
display: inline-flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #e11d48;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
`,
title: css`
margin: 10px 0 0;
color: #020617;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
letter-spacing: 0;
`,
subtitle: css`
margin: 6px 0 0;
max-width: 640px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
`,
headerActions: css`
display: flex;
align-items: center;
gap: 12px;
`,
countBox: css`
min-width: 90px;
padding: 8px 12px;
border-radius: 16px;
border: 1px solid #ffe4e6;
background: rgba(255, 241, 242, 0.76);
text-align: right;
`,
countLabel: css`
color: #f43f5e;
font-size: 11px;
line-height: 1.2;
font-weight: 600;
`,
countValue: css`
margin-top: 4px;
color: #020617;
font-size: 22px;
line-height: 1;
font-weight: 700;
`,
closeButton: css`
height: 40px;
padding: 0 14px;
border: 1px solid #e4e4e7;
border-radius: 12px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
border-color: #d4d4d8;
color: #0f172a;
background: #fafafa;
}
`,
body: css`
max-height: min(68dvh, 720px);
overflow-y: auto;
padding: 18px 20px 20px;
scrollbar-gutter: stable;
${responsive.xs} {
max-height: 64dvh;
padding: 16px 18px 18px;
}
`,
section: css`
& + & {
margin-top: 18px;
}
`,
sectionHeader: css`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
`,
sectionTitle: css`
color: #0f172a;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
`,
sectionLine: css`
height: 1px;
flex: 1;
background: rgba(228, 228, 231, 0.86);
`,
sectionCount: css`
color: #94a3b8;
font-size: 12px;
font-weight: 600;
`,
grid: css`
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
@media (min-width: 1680px) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
${responsive.lg} {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
${responsive.md} {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
${responsive.xs} {
grid-template-columns: 1fr;
}
`,
card: css`
position: relative;
min-height: 84px;
overflow: hidden;
display: block;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(228, 228, 231, 0.88);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
border-color: #fecdd3;
box-shadow: 0 16px 32px rgba(244, 63, 94, 0.12);
color: inherit;
}
&:focus-visible {
outline: 2px solid rgba(244, 63, 94, 0.48);
outline-offset: 2px;
}
`,
cardCurrent: css`
border-color: #fda4af;
background: linear-gradient(
180deg,
rgba(255, 241, 242, 0.95),
rgba(255, 255, 255, 0.96)
);
`,
cardGlow: css`
position: absolute;
inset: 0 0 auto;
height: 56px;
background: radial-gradient(
circle at top right,
rgba(251, 113, 133, 0.14),
transparent 50%
);
opacity: 0;
transition: opacity 0.2s ease;
a:hover & {
opacity: 1;
}
`,
cardTop: css`
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
`,
cardMain: css`
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
`,
logo: css`
width: 36px;
height: 36px;
flex-shrink: 0;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.86);
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
`,
logoFallback: css`
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #b42318, #f43f5e);
color: #ffffff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 8px 18px rgba(180, 35, 24, 0.22);
`,
cardText: css`
min-width: 0;
`,
cardTitle: css`
overflow: hidden;
color: #020617;
font-size: 14px;
line-height: 1.35;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardDesc: css`
margin-top: 3px;
overflow: hidden;
color: #64748b;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardActions: css`
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
`,
currentBadge: css`
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #ffe4e6;
color: #be123c;
font-size: 11px;
font-weight: 700;
`,
openIcon: css`
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
transition: background 0.2s ease, color 0.2s ease;
a:hover & {
background: #fff1f2;
color: #e11d48;
}
`,
tags: css`
position: relative;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
`,
tag: css`
display: inline-flex;
align-items: center;
max-width: 100%;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #f4f4f5;
color: #3f3f46;
font-size: 11px;
line-height: 1;
font-weight: 600;
`,
roleTag: css`
background: #f1f5f9;
color: #334155;
`,
groupTag: css`
background: #fff1f2;
color: #be123c;
`,
skeletonCard: css`
min-height: 84px;
padding: 10px;
border-radius: 16px;
border: 1px solid #e4e4e7;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
`,
empty: css`
min-height: 288px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #e4e4e7;
border-radius: 28px;
background: rgba(250, 250, 250, 0.82);
`,
}));
function ApplicationCard({ application }) {
const { styles, cx } = useStyles();
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
function ApplicationCard({ application, current }) {
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={cx(styles.card, current && styles.cardCurrent)}
>
<div className={styles.cardGlow} />
<div className={styles.cardTop}>
<div className={styles.cardMain}>
<Surface
aria-hidden="true"
className="rc-application-identity-well"
variant="tertiary"
>
<Avatar
className="rc-application-identity-avatar"
color="accent"
variant="soft"
>
{application.logo ? (
<img
<Avatar.Image
alt=""
className="object-contain p-1.5"
src={application.logo}
alt={application.title}
className={styles.logo}
/>
) : (
<div className={styles.logoFallback}>
{application.title.slice(0, 1)}
</div>
)}
<div className={styles.cardText}>
<div className={styles.cardTitle}>{application.title}</div>
<div className={styles.cardDesc}>{application.description}</div>
</div>
</div>
<div className={styles.cardActions}>
{current ? <span className={styles.currentBadge}>当前</span> : null}
<span className={styles.openIcon}>
<ExportOutlined />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className={styles.tags}>
{accessLabel ? (
<span className={styles.tag}>{accessLabel}</span>
) : null}
{firstRole ? (
<span className={`${styles.tag} ${styles.roleTag}`}>
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className={`${styles.tag} ${styles.groupTag}`}>
{firstGroup}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<Card.Title className="break-words" title={application.title}>
{application.title}
</Card.Title>
<Card.Description
className="truncate"
title={application.description}
>
{application.description || application.name || '应用'}
</Card.Description>
</span>
) : null}
</div>
{current ? (
<Chip color="accent" size="sm">
当前
</Chip>
) : null}
</a>
</Card.Content>
</Card>
);
}
function LoadingSkeleton() {
const { styles } = useStyles();
return (
<div className={styles.grid}>
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className={styles.skeletonCard}>
<Skeleton active avatar paragraph={{ rows: 2 }} title={false} />
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const { styles } = useStyles();
const [loadedSiteInfo, setLoadedSiteInfo] = useState(null);
export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
if (!Array.isArray(userInfo?.applications)) return [];
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.map((application) => normalizeApplication(application, application))
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const effectiveSiteInfo = siteInfo || loadedSiteInfo;
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, effectiveSiteInfo?.group);
}, [applications, effectiveSiteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open || siteInfo?.group || loadedSiteInfo) {
return;
}
let cancelled = false;
const loadSiteInfo = async () => {
try {
const response = await getSystemInfo();
if (cancelled) {
return;
}
setLoadedSiteInfo({
...(response?.site || response || {}),
key: response?.key,
});
} catch (error) {
if (!cancelled) {
setLoadedSiteInfo({});
}
}
};
loadSiteInfo();
return () => {
cancelled = true;
};
}, [loadedSiteInfo, open, siteInfo?.group]);
const currentApplicationName = useMemo(
() => applications.find(isCurrentApplication)?.name || '',
[applications],
);
useEffect(() => {
if (!open) {
return;
}
if (!open) return;
let cancelled = false;
const loadApplications = async () => {
......@@ -749,24 +187,16 @@ export default function ApplicationSwitcherDialog({
try {
const response = await getApplications();
const fetchedApplications = extractApplications(response);
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
if (cancelled) return;
setApplications(
mergeApplications(
extractApplications(response),
fallbackApplications,
),
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
} catch {
if (cancelled) return;
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
......@@ -774,97 +204,77 @@ export default function ApplicationSwitcherDialog({
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Modal
open={open}
centered
footer={null}
closeIcon={null}
width="min(92vw, 1472px)"
onCancel={onClose}
className={styles.modal}
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
>
<div className={styles.header}>
<div>
<div className={styles.eyebrow}>
<ThunderboltOutlined />
Applications
</div>
<h2 className={styles.title}>应用切换</h2>
<p className={styles.subtitle}>
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className={styles.headerActions}>
<div className={styles.countBox}>
<div className={styles.countLabel}>可见应用</div>
<div className={styles.countValue}>{applications.length}</div>
</div>
<button
type="button"
onClick={onClose}
className={styles.closeButton}
<Modal.Container
className="w-full sm:w-full"
placement="center"
scroll="inside"
size="lg"
>
关闭
</button>
</div>
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<Modal.Heading>应用切换</Modal.Heading>
<Description>选择你可访问的应用</Description>
</div>
<div className={styles.body}>
</Modal.Header>
<Modal.Body>
{status === 'error' && errorMessage ? (
<Alert
showIcon
type="warning"
icon={<ExclamationCircleOutlined />}
message={errorMessage}
style={{ marginBottom: 16 }}
/>
<Alert status="warning">
<Alert.Indicator>
<ExclamationTriangleIcon size={20} />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
applicationSections.map((section) => (
<section key={section.key} className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>{section.label}</div>
<span className={styles.sectionLine} />
<span className={styles.sectionCount}>
{section.applications.length}
</span>
</div>
<div className={styles.grid}>
{section.applications.map((application) => (
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
key={application.name}
/>
))}
</div>
</section>
))
) : (
<div className={styles.empty}>
<Empty
image={<AppstoreOutlined style={{ fontSize: 42 }} />}
description={
<span>
暂无可切换应用
<br />
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</span>
}
/>
</div>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>
当前账号还没有分配其它应用权限。
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</div>
</Modal>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import {
CopyOutlined,
PaperClipOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import {
ChatAttachment,
ChatAttachmentGroup,
ChatAttachmentInput,
} from '@heroui-pro/react/chat-attachment';
import { ChatConversation } from '@heroui-pro/react/chat-conversation';
import { ChatMessage } from '@heroui-pro/react/chat-message';
import { Markdown, StreamMarkdown } from '@heroui-pro/react/markdown';
import { PromptInput } from '@heroui-pro/react/prompt-input';
import { PromptSuggestion } from '@heroui-pro/react/prompt-suggestion';
import { Disclosure, Spinner, Surface, toast } from '@heroui/react';
function MessageAttachments({ files = [] }) {
if (!files.length) return null;
return (
<ChatAttachmentGroup>
{files.map((file, index) => (
<ChatAttachment
key={file.uid || file.url || `${file.name}-${index}`}
mimeType={file.type}
name={file.name}
size={file.size}
src={file.url}
/>
))}
</ChatAttachmentGroup>
);
}
function ThinkingDisclosure({ label, value }) {
if (!value) return null;
return (
<Disclosure variant="secondary">
<Disclosure.Heading>
<Disclosure.Trigger>
<Disclosure.Indicator />
{label}
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>
<Markdown>{value}</Markdown>
</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
);
}
function AssistantMessage({
item,
isLast,
onRegenerate,
renderAssistantContent,
renderAssistantExtras,
}) {
const content = item.message?.content || '';
const isStreaming = item.status === 'loading';
return (
<ChatMessage.Assistant>
<ChatMessage.Avatar alt="AI 助手" fallback="AI" />
<ChatMessage.Body>
<ChatMessage.Bubble>
<ChatMessage.Content>
<ThinkingDisclosure
label={content ? '思考完成' : '正在思考...'}
value={item.message?.think}
/>
{item.message?.tools?.map((tool, index) => (
<ThinkingDisclosure
key={`${tool.function?.name || 'tool'}-${index}`}
label={`工具调用:${tool.function?.name || '未命名工具'}`}
value={`\`\`\`json\n${tool.function?.arguments || ''}\n\`\`\``}
/>
))}
{renderAssistantContent ? (
renderAssistantContent(item)
) : item.message?.auditResult?.kind !== 'checklist' ? (
isStreaming ? (
<StreamMarkdown isStreaming>{content}</StreamMarkdown>
) : (
<Markdown>{content}</Markdown>
)
) : null}
{renderAssistantExtras?.(item)}
</ChatMessage.Content>
</ChatMessage.Bubble>
<ChatMessage.Actions>
<ChatMessage.Action
aria-label="复制回复"
tooltip="复制回复"
onPress={async () => {
await navigator.clipboard.writeText(content);
toast.success('已复制到剪贴板');
}}
>
<CopyOutlined />
</ChatMessage.Action>
{isLast ? (
<ChatMessage.Action
aria-label="重新生成"
tooltip="重新生成"
onPress={onRegenerate}
>
<ReloadOutlined />
</ChatMessage.Action>
) : null}
</ChatMessage.Actions>
</ChatMessage.Body>
</ChatMessage.Assistant>
);
}
function UserMessage({ item, userInfo }) {
return (
<ChatMessage.User>
<ChatMessage.Body>
<ChatMessage.Bubble>
<ChatMessage.Content>{item.message?.content}</ChatMessage.Content>
<MessageAttachments files={item.message?.files} />
</ChatMessage.Bubble>
</ChatMessage.Body>
<ChatMessage.Avatar
alt={userInfo?.display_name || '当前用户'}
fallback={userInfo?.display_name?.[0] || '我'}
src={userInfo?.avatar}
/>
</ChatMessage.User>
);
}
function ToolMessage({ item }) {
return (
<ChatMessage.Assistant>
<ChatMessage.Avatar alt="工具" fallback="T" />
<ChatMessage.Body>
<ChatMessage.Bubble>
<ChatMessage.Content>
<ThinkingDisclosure
label={`工具调用:${item.message?.tool || '运行结果'}`}
value={item.message?.think}
/>
<Markdown>{item.message?.content || ''}</Markdown>
</ChatMessage.Content>
</ChatMessage.Bubble>
</ChatMessage.Body>
</ChatMessage.Assistant>
);
}
function PromptGroups({ groups = [], onSelect }) {
if (!groups.length) return null;
return (
<PromptSuggestion className="rc-chat-suggestions" variant="card">
{groups.map((group) => (
<PromptSuggestion.Group key={group.key} label={group.label}>
<PromptSuggestion.Items
className={`rc-chat-suggestion-items${
group.children?.length === 1
? ' rc-chat-suggestion-items--single'
: ''
}`}
>
{(group.children || []).map((item) => (
<PromptSuggestion.Item
key={item.key}
role="button"
tabIndex={0}
onClick={() => onSelect(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(item);
}
}}
>
<PromptSuggestion.ItemTitle>
{item.label || item.description || item.title}
</PromptSuggestion.ItemTitle>
{item.label && item.description ? (
<PromptSuggestion.ItemDescription>
{item.description}
</PromptSuggestion.ItemDescription>
) : null}
</PromptSuggestion.Item>
))}
</PromptSuggestion.Items>
</PromptSuggestion.Group>
))}
</PromptSuggestion>
);
}
export default function ChatWorkspace({
canConfigure,
children,
description,
files = [],
inputValue,
isLoading,
knowledgeBases = [],
messages = [],
onConfigure,
onFilesSelected,
onInputChange,
onQuestion,
onRegenerate,
onRemoveFile,
onStop,
onSubmit,
onSuggestion,
questions = [],
renderAssistantExtras,
renderAssistantContent,
suggestionGroups = [],
title,
userInfo,
}) {
const visibleMessages = messages.filter(
(item) => !item.message?.metadata?.hidden,
);
return (
<div className="rc-chat-workspace">
<ChatConversation className="rc-chat-conversation">
<ChatConversation.Content
className={`rc-chat-conversation-content${
visibleMessages.length ? '' : ' rc-chat-conversation-content--empty'
}`}
>
{title ? (
<Surface className="rc-chat-context" variant="default">
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
{knowledgeBases.length ? (
<p>
已连接知识库:
{knowledgeBases
.map((item) => item.title || item.name)
.join(' / ')}
</p>
) : null}
</Surface>
) : null}
{visibleMessages.map((item, index) => {
const isLast = index === visibleMessages.length - 1;
if (item.message?.role === 'user') {
return (
<UserMessage item={item} key={item.id} userInfo={userInfo} />
);
}
if (item.message?.role === 'tool') {
return <ToolMessage item={item} key={item.id} />;
}
return (
<AssistantMessage
isLast={isLast}
item={item}
key={item.id}
onRegenerate={onRegenerate}
renderAssistantContent={renderAssistantContent}
renderAssistantExtras={renderAssistantExtras}
/>
);
})}
{!visibleMessages.length ? (
<PromptGroups groups={suggestionGroups} onSelect={onSuggestion} />
) : null}
{children}
<ChatConversation.ScrollAnchor />
</ChatConversation.Content>
<ChatConversation.ScrollButton tooltip="滚动到最新消息" />
</ChatConversation>
<div className="rc-chat-composer-stack">
{questions.length && !visibleMessages.length ? (
<PromptSuggestion>
<PromptSuggestion.Items>
{questions.map((question) => (
<PromptSuggestion.Item
key={question}
onPress={() => onQuestion(question)}
>
{question}
</PromptSuggestion.Item>
))}
</PromptSuggestion.Items>
</PromptSuggestion>
) : null}
<PromptInput
layout="stacked"
lockInputOnRun
maxHeight={240}
size="lg"
status={isLoading ? 'streaming' : 'ready'}
value={inputValue}
onStop={onStop}
onSubmit={onSubmit}
onValueChange={onInputChange}
>
<PromptInput.Shell>
{files.length ? (
<PromptInput.Attachments>
<ChatAttachmentGroup>
{files.map((file, index) => (
<ChatAttachment
key={file.uid || `${file.name}-${index}`}
mimeType={file.type}
name={file.name}
size={file.size}
src={file.response?.url}
>
<ChatAttachment.Remove
aria-label={`移除${file.name}`}
onPress={() => onRemoveFile(file)}
/>
</ChatAttachment>
))}
</ChatAttachmentGroup>
</PromptInput.Attachments>
) : null}
<PromptInput.Content>
<PromptInput.TextArea placeholder="请输入消息内容" />
</PromptInput.Content>
<PromptInput.Toolbar>
<PromptInput.ToolbarStart>
{canConfigure ? (
<PromptInput.Action
tooltip="配置审计提示词"
onPress={onConfigure}
>
<SettingOutlined />
</PromptInput.Action>
) : null}
<ChatAttachmentInput
multiple
disabled={isLoading}
onFilesSelected={onFilesSelected}
>
<ChatAttachmentInput.Trigger
render={(triggerProps) => (
<PromptInput.Action {...triggerProps} tooltip="上传附件">
<PaperClipOutlined />
</PromptInput.Action>
)}
/>
</ChatAttachmentInput>
</PromptInput.ToolbarStart>
<PromptInput.ToolbarEnd>
{files.some((file) => file.status === 'uploading') ? (
<Spinner size="sm" />
) : null}
<PromptInput.Send
aria-label={isLoading ? '停止生成' : '发送消息'}
isDisabled={!inputValue.trim() && !isLoading}
status={isLoading ? 'streaming' : 'ready'}
onStop={onStop}
/>
</PromptInput.ToolbarEnd>
</PromptInput.Toolbar>
</PromptInput.Shell>
</PromptInput>
</div>
</div>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({
actions,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{actions}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import {
AppGrid,
Comment,
Delete,
Edit,
LogOut,
More,
Pin,
Plus,
} from './AppIcons';
function getUserDisplay(userInfo) {
const displayName =
userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger
aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger"
>
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? (
<Avatar.Image alt={user.displayName} src={userInfo.avatar} />
) : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span
className="app-sidebar-user-copy rc-sidebar-user-copy"
data-sidebar="label"
>
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') onLogout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function ConversationActions({ conversation, onDelete, onPin, onRename }) {
return (
<Dropdown>
<Dropdown.Trigger aria-label={`${conversation.label}会话操作`}>
<More size={16} />
</Dropdown.Trigger>
<Dropdown.Popover placement="bottom end">
<Dropdown.Menu
aria-label="会话操作"
onAction={(key) => {
if (key === 'pin') onPin(conversation);
if (key === 'rename') onRename(conversation);
if (key === 'delete') onDelete(conversation);
}}
>
<Dropdown.Item
id="pin"
textValue={conversation.pinned ? '取消置顶' : '置顶'}
>
<div className="rc-sidebar-user-menu-item">
<Label>{conversation.pinned ? '取消置顶' : '置顶'}</Label>
<Pin size={14} />
</div>
</Dropdown.Item>
<Dropdown.Item id="rename" textValue="重命名">
<div className="rc-sidebar-user-menu-item">
<Label>重命名</Label>
<Edit size={14} />
</div>
</Dropdown.Item>
<Dropdown.Item id="delete" textValue="删除" variant="danger">
<div className="rc-sidebar-user-menu-item">
<Label>删除</Label>
<Delete size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({
activeKey,
conversations,
onDeleteConversation,
onLogout,
onNewConversation,
onOpenApplicationSwitcher,
onOpenConversation,
onPinConversation,
onRenameConversation,
userInfo,
}) {
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xyLogoWhite}
/>
</div>
<div
className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APPLICATION_DISPLAY_NAME}
</span>
</div>
<div
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu aria-label="会话操作">
<Sidebar.MenuItem
id="new-conversation"
textValue="新建会话"
onAction={onNewConversation}
>
<Sidebar.MenuIcon>
<Plus size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>新建会话</Sidebar.MenuLabel>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.Group closeMobileOnAction>
<Sidebar.GroupLabel>历史会话</Sidebar.GroupLabel>
{conversations.length > 0 ? (
<Sidebar.Menu aria-label="历史会话">
{conversations.map((conversation) => (
<Sidebar.MenuItem
id={conversation.key}
isCurrent={conversation.key === activeKey}
key={conversation.key}
textValue={conversation.label}
tooltip={conversation.label}
onAction={() => onOpenConversation(conversation.key)}
>
<Sidebar.MenuIcon>
<Comment size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{conversation.label}</Sidebar.MenuLabel>
{conversation.pinned ? (
<Sidebar.MenuChip aria-label="已置顶">
置顶
</Sidebar.MenuChip>
) : null}
<Sidebar.MenuActions>
<ConversationActions
conversation={conversation}
onDelete={onDeleteConversation}
onPin={onPinConversation}
onRename={onRenameConversation}
/>
</Sidebar.MenuActions>
</Sidebar.MenuItem>
))}
</Sidebar.Menu>
) : (
<p className="px-2 py-3">还没有历史会话</p>
)}
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 className="sr-only" slot="title">
{APPLICATION_DISPLAY_NAME}导航
</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { getCaptcha, login } from '@/services/DataService';
import {
LockOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons';
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { Alert, Button, Flex, Modal, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import { useCallback, useEffect, useState } from 'react';
const useStyle = createStyles(({ css }) => {
return {
modal: css`
.ant-modal-content {
border-radius: 30px;
padding: 0;
overflow: hidden;
background: radial-gradient(
circle at top right,
rgba(251, 207, 232, 0.26),
transparent 20%
),
linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);
function getRequestErrorMessage(error, fallback) {
const serverMessage = error?.response?.data?.message;
if (typeof serverMessage === 'string' && serverMessage.trim()) {
return serverMessage;
}
return fallback;
}
.ant-modal-body {
padding: 0;
}
`,
shell: css`
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
min-height: 540px;
`,
visual: css`
padding: 38px 34px;
background: radial-gradient(
circle at top left,
rgba(200, 30, 58, 0.22),
transparent 22%
),
linear-gradient(160deg, #0f172a 0%, #1e293b 100%);
color: #f8fafc;
display: flex;
flex-direction: column;
justify-content: space-between;
`,
visualBadge: css`
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.04em;
`,
visualTitle: css`
margin-top: 24px;
h2 {
margin: 0;
font-size: 34px;
line-height: 1.15;
color: #f8fafc;
}
p {
margin: 14px 0 0;
font-size: 15px;
line-height: 1.8;
color: rgba(226, 232, 240, 0.78);
}
`,
visualList: css`
display: grid;
gap: 12px;
margin-top: 28px;
div {
border-radius: 18px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
strong {
display: block;
font-size: 14px;
color: #f8fafc;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: rgba(226, 232, 240, 0.72);
}
`,
formPane: css`
padding: 34px 32px;
display: flex;
align-items: center;
justify-content: center;
.ant-pro-form-login-container {
width: 100%;
box-shadow: none;
padding: 0;
}
.ant-pro-form-login-logo {
width: 58px;
height: 58px;
}
.ant-pro-form-login-title {
color: #0f172a;
font-size: 28px;
line-height: 1.2;
}
.ant-pro-form-login-subtitle {
color: #64748b;
font-size: 14px;
}
.ant-input-affix-wrapper,
.ant-input {
border-radius: 16px !important;
min-height: 48px;
}
.ant-btn-primary {
height: 46px;
border-radius: 16px;
font-weight: 600;
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
border: none;
}
`,
formInner: css`
width: 100%;
max-width: 360px;
`,
error: css`
margin-bottom: 20px;
border-radius: 14px;
`,
captchaRow: css`
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 12px;
align-items: flex-start;
.ant-form-item {
margin-bottom: 0;
}
`,
captchaButton: css`
height: 48px;
border-radius: 16px;
padding: 0 10px;
overflow: hidden;
border-color: #e2e8f0;
color: #64748b;
.ant-btn-icon {
display: inline-flex;
}
`,
captchaImage: css`
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
display: block;
}
`,
};
});
export default (props) => {
const { onError, onSuccess } = props;
const [errorMessage, setErrorMessage] = useState(null);
export default function LoginModal({ onError, onSuccess, open }) {
const [identity, setIdentity] = useState('');
const [password, setPassword] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const formRef = useRef();
const { styles } = useStyle();
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const refreshCaptcha = useCallback(async () => {
setCaptchaLoading(true);
......@@ -202,8 +29,10 @@ export default (props) => {
const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (error) {
const nextErrorMessage =
error.response?.data?.message || error.message || '验证码加载失败';
const nextErrorMessage = getRequestErrorMessage(
error,
'验证码加载失败,请点击刷新重试',
);
setCaptchaData(null);
setErrorMessage(nextErrorMessage);
} finally {
......@@ -212,167 +41,177 @@ export default (props) => {
}, []);
useEffect(() => {
if (props.open) {
setErrorMessage(null);
formRef.current?.setFieldsValue({ captcha: undefined });
if (!open) return;
setErrorMessage('');
setCaptchaCode('');
refreshCaptcha();
}
}, [props.open, refreshCaptcha]);
}, [open, refreshCaptcha]);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
setErrorMessage('');
return (
<Modal
{...props}
closable={false}
footer={null}
width={920}
className={styles.modal}
>
<div className={styles.shell}>
<div className={styles.visual}>
<div>
<div className={styles.visualBadge}>Enterprise AI Workspace</div>
<div className={styles.visualTitle}>
<Typography.Title level={2}>
让业务知识真正进入对话流程
</Typography.Title>
<Typography.Paragraph>
统一连接企业知识库、智能体工具链、文件理解与流程型操作,让每次提问都更接近可执行结果。
</Typography.Paragraph>
</div>
<div className={styles.visualList}>
<div>
<strong>知识与上下文统一</strong>
<span>支持话题问答、附件内容提取与历史会话连续理解。</span>
</div>
<div>
<strong>智能体可直接协作</strong>
<span>在同一工作台中触发专属助手和 MCP 工具调用。</span>
</div>
<div>
<strong>更适合内部业务落地</strong>
<span>保留稳定功能栈,同时提供更清晰一致的产品体验。</span>
</div>
</div>
</div>
</div>
<div className={styles.formPane}>
<div className={styles.formInner}>
<LoginForm
formRef={formRef}
logo="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
subTitle="登录后继续使用统一 AI 对话工作台"
onFinish={async (form) => {
try {
const userInfo = await login(
form.username,
form.password,
form.captcha,
identity,
password,
captchaCode,
captchaData?.token,
);
localStorage.setItem(
'current_user',
JSON.stringify({
...userInfo,
token: undefined,
}),
JSON.stringify({ ...userInfo, token: undefined }),
);
setErrorMessage(null);
onSuccess?.(userInfo);
} catch (error) {
const nextErrorMessage =
error.response?.data?.message ||
error.message ||
'网络错误';
const nextErrorMessage = getRequestErrorMessage(
error,
'登录失败,请检查网络后重试',
);
setErrorMessage(nextErrorMessage);
formRef.current?.setFieldsValue({ captcha: undefined });
setCaptchaCode('');
refreshCaptcha();
onError?.(nextErrorMessage, error);
} finally {
setLoading(false);
}
}}
};
if (!open) return null;
return (
<main className="login-page rc-login-page">
<section className="login-shell rc-login-shell">
<Card className="login-card rc-login-card">
<Card.Content>
<div className="login-brand login-card-brand rc-login-brand">
<span className="login-brand-logo">
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
</span>
<strong>{APPLICATION_DISPLAY_NAME}</strong>
</div>
<form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
>
<Flex vertical>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder="用户名"
rules={[
{
required: true,
message: '请输入用户名',
},
]}
<Label>账号</Label>
<Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined}
placeholder="请输入账号"
variant="secondary"
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder="密码"
rules={[
{
required: true,
message: '请输入密码',
},
]}
</TextField>
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
type="password"
value={password}
onChange={setPassword}
>
<Label>密码</Label>
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
variant="secondary"
/>
<div className={styles.captchaRow}>
<ProFormText
name="captcha"
fieldProps={{
size: 'large',
prefix: (
<SafetyCertificateOutlined className={'prefixIcon'} />
),
}}
placeholder="验证码"
rules={[
{
required: true,
message: '请输入验证码',
},
]}
</TextField>
<div
className="login-field"
data-has-value={captchaCode ? 'true' : undefined}
>
<Label>验证码</Label>
<div className="login-captcha-row">
<Input
aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined}
disabled={loading}
placeholder="请输入验证码"
required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/>
<Button
htmlType="button"
className={styles.captchaButton}
loading={captchaLoading}
disabled={captchaLoading}
icon={captchaData?.image ? undefined : <ReloadOutlined />}
onClick={() => {
setErrorMessage(null);
formRef.current?.setFieldsValue({ captcha: undefined });
aria-label="刷新验证码"
className="login-captcha-button"
isDisabled={loading || captchaLoading}
variant="secondary"
onPress={() => {
setErrorMessage('');
setCaptchaCode('');
refreshCaptcha();
}}
>
{captchaData?.image ? (
<span
className={styles.captchaImage}
className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
'刷新'
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</Button>
</div>
{errorMessage && (
<Alert
className={styles.error}
message={errorMessage}
type="error"
showIcon
/>
)}
</Flex>
</LoginForm>
</div>
</div>
</div>
</Modal>
{errorMessage ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
isDisabled={
loading ||
captchaLoading ||
!identity ||
!password ||
!captchaCode
}
isPending={loading}
type="submit"
variant="primary"
>
{loading ? '登录中...' : '登录'}
</Button>
</form>
</Card.Content>
</Card>
</section>
</main>
);
};
}
export const APPLICATION_DISPLAY_NAME = 'AI 应用';
export const APPLICATION_BASE_PATH = '/ai_chat/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
.rc-app-shell {
position: fixed;
inset: 0;
display: flex;
overflow: hidden;
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
}
.rc-sidebar,
.rc-mobile-sidebar {
display: flex;
flex-direction: column;
color: var(--foreground);
background: var(--rc-bg-canvas);
}
.rc-sidebar {
position: relative;
z-index: 30;
width: 15rem;
height: 100%;
flex: none;
padding: var(--rc-space-4) var(--rc-space-3) var(--rc-space-3);
transition: width var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-sidebar.is-collapsed {
width: 4rem;
}
.rc-sidebar-brand {
width: 100%;
min-width: 0;
display: flex;
min-height: 3rem;
align-items: center;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
color: var(--foreground);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
}
.rc-sidebar-brand.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-brand-mark {
display: flex;
width: 2.75rem;
height: 1.5rem;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rc-sidebar-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.rc-sidebar-brand-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: var(--rc-space-2);
}
.rc-sidebar-app-icon {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
flex: none;
align-items: center;
justify-content: center;
border-radius: var(--rc-radius-control);
color: var(--rc-brand-light);
background: rgb(255 255 255 / 8%);
}
.rc-sidebar-title {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-app-switcher {
flex: none;
}
.rc-sidebar-content {
min-height: 0;
flex: 1;
overflow-y: auto;
padding-top: var(--rc-space-4);
scrollbar-width: thin;
}
.rc-sidebar-group + .rc-sidebar-group {
margin-top: var(--rc-space-5);
}
.rc-sidebar-group-label {
margin: 0 0 var(--rc-space-2);
padding: 0 var(--rc-space-3);
color: var(--muted);
font-size: var(--rc-text-xs);
font-weight: var(--rc-font-medium);
line-height: var(--rc-line-normal);
}
.rc-sidebar-menu {
display: grid;
gap: var(--rc-space-1);
}
.rc-sidebar-item {
position: relative;
display: flex;
min-width: 0;
height: 2.5rem;
align-items: center;
gap: var(--rc-space-3);
padding: 0 var(--rc-space-3);
border-radius: var(--rc-radius-control);
color: var(--muted);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-medium);
text-decoration: none;
transition: color var(--rc-motion-fast) var(--rc-ease-standard),
background var(--rc-motion-fast) var(--rc-ease-standard);
}
.rc-sidebar-item:hover {
color: var(--foreground);
background: var(--default);
}
.rc-sidebar-item.is-active {
color: var(--foreground);
background: var(--default);
}
.rc-sidebar-item.is-active::before {
position: absolute;
inset-block: 0.5rem;
left: 0;
width: 0.1875rem;
border-radius: 0 999px 999px 0;
background: var(--rc-brand-primary);
content: '';
}
.rc-sidebar-item.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-item-icon {
flex: none;
font-size: 1rem;
}
.rc-sidebar-item.is-active .rc-sidebar-item-icon {
color: var(--rc-brand-light);
}
.rc-sidebar-item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-state {
margin: var(--rc-space-2);
padding: var(--rc-space-3);
border-radius: var(--rc-radius-control);
color: var(--muted);
background: var(--default);
font-size: var(--rc-text-xs);
}
.rc-sidebar-state.is-error {
color: rgb(254 202 202);
background: rgb(201 54 54 / 14%);
}
.rc-sidebar.is-collapsed .rc-sidebar-state {
display: none;
}
.rc-sidebar-user {
display: flex;
width: 100%;
min-height: 3rem;
align-items: center;
gap: var(--rc-space-3);
padding: 0.375rem var(--rc-space-2);
border: 0;
border-radius: var(--rc-radius-container);
color: var(--foreground);
background: transparent;
cursor: pointer;
text-align: left;
}
.rc-sidebar-user:hover,
.rc-sidebar-user:focus-visible {
background: rgb(255 255 255 / 7%);
}
.rc-sidebar-user:focus-visible,
.rc-sidebar-item:focus-visible {
outline: 2px solid var(--rc-brand-light);
outline-offset: 2px;
}
.rc-sidebar-user.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-avatar {
flex: none;
background: var(--rc-brand-primary);
}
.rc-sidebar-user-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.rc-sidebar-user-copy strong,
.rc-sidebar-user-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-user-copy strong {
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-medium);
}
.rc-sidebar-user-copy small {
margin-top: 0.125rem;
color: var(--muted);
font-size: var(--rc-text-xs);
}
.rc-sidebar-logout {
flex: none;
color: var(--muted);
font-size: 0.875rem;
}
.rc-app-main {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.rc-navbar {
flex: none;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.rc-navbar .navbar__header {
width: 100%;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
gap: var(--rc-space-3);
padding: 0 var(--rc-navbar-inline-padding);
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-trigger-size);
min-width: var(--rc-navbar-trigger-size);
max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size);
margin-inline-start: 0;
}
.rc-navbar-icon-button.button,
.rc-navbar-icon-button.toggle-button {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--foreground);
box-shadow: none;
line-height: 1;
}
.rc-navbar [data-slot='sidebar-trigger'] {
background: transparent !important;
}
.rc-navbar-icon-button.button,
.rc-navbar-icon-button.toggle-button {
background: var(--default) !important;
}
.rc-navbar [data-slot='sidebar-trigger']:hover,
.rc-navbar [data-slot='sidebar-trigger'][data-hovered='true'],
.rc-navbar [data-slot='sidebar-trigger']:focus-visible {
color: var(--foreground);
background: var(--default) !important;
}
.rc-navbar [data-slot='sidebar-trigger']:active,
.rc-navbar [data-slot='sidebar-trigger'][data-pressed='true'] {
background: var(--default-hover) !important;
}
.rc-navbar-icon-button.button:hover,
.rc-navbar-icon-button.button[data-hovered='true'],
.rc-navbar-icon-button.toggle-button:hover,
.rc-navbar-icon-button.toggle-button[data-hovered='true'] {
color: var(--foreground);
background: var(--default-hover) !important;
}
.rc-navbar [data-slot='sidebar-trigger'] > span,
.rc-navbar-icon-button > span,
.rc-navbar-icon-button .anticon {
display: grid;
width: 1rem;
height: 1rem;
place-items: center;
line-height: 1;
}
.rc-navbar [data-slot='sidebar-trigger'] > svg,
.rc-navbar [data-slot='sidebar-trigger'] > span > svg,
.rc-navbar-icon-button > svg,
.rc-navbar-icon-button .anticon > svg {
display: block;
width: 1rem;
height: 1rem;
margin: 0;
}
.rc-navbar-actions {
display: flex;
align-items: center;
gap: var(--rc-space-2);
}
.rc-navbar-title {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--foreground);
font-size: var(--rc-navbar-title-size);
font-weight: var(--rc-navbar-title-weight);
line-height: var(--rc-navbar-title-line-height);
letter-spacing: 0 !important;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-navbar-spacer {
flex: 1;
}
.app-shell-content-scroll {
min-width: 0;
background: var(--rc-bg-canvas);
overscroll-behavior: contain;
}
.rc-app-page {
width: 100%;
max-width: none;
min-width: 0;
min-height: 100%;
margin: 0;
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
padding-inline-start: var(--rc-page-inline-start);
padding-inline-end: var(--rc-page-inline-end);
overflow-x: clip;
}
/* Shared page rhythm. These classes control layout only, never HeroUI internals. */
.rc-page-stack,
.rc-page-section,
.rc-list-stack,
.rc-list-data-stack {
display: flex;
min-width: 0;
flex-direction: column;
}
.rc-page-stack {
gap: var(--rc-page-section-gap);
}
.rc-page-section {
gap: var(--rc-page-module-gap);
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
}
.rc-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--rc-related-item-gap);
}
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.rc-page-toolbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--rc-page-toolbar-gap);
}
.rc-page-toolbar-responsive {
flex-direction: column;
align-items: stretch;
}
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgb(15 23 42 / 32%);
transition: opacity var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-mobile-sidebar-panel {
position: fixed;
inset-block: 0;
left: 0;
z-index: 210;
width: min(19rem, calc(100vw - 3rem));
transition: transform var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-mobile-sidebar {
position: relative;
width: 100%;
height: 100%;
padding: var(--rc-space-4) var(--rc-space-3) var(--rc-space-3);
box-shadow: var(--rc-shadow-overlay);
}
.rc-mobile-sidebar-close.button {
position: absolute;
top: var(--rc-space-3);
right: var(--rc-space-3);
z-index: 1;
width: 2rem;
min-width: 2rem;
height: 2rem;
border-radius: 999px;
color: rgb(212 212 216);
}
@media (max-width: 1023px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-8);
}
.rc-sidebar {
display: none;
}
.rc-navbar {
min-height: 4rem;
}
}
@media (min-width: 768px) {
.rc-page-toolbar-responsive {
flex-direction: row;
align-items: center;
}
}
@media (max-width: 640px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-4);
--rc-page-padding-block-start: var(--rc-space-4);
--rc-page-padding-block-end: var(--rc-space-6);
--rc-page-section-gap: var(--rc-space-6);
--rc-page-module-gap: var(--rc-space-4);
}
.rc-app-page {
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex-basis: var(--rc-navbar-icon-button-size);
}
}
/* HeroUI Pro offcanvas shell contract */
.rc-app-shell.sidebar__provider {
position: fixed;
inset: 0;
width: 100%;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--rc-bg-canvas);
}
.rc-app-shell .sidebar__offcanvas-wrapper {
--sidebar-width: 15rem;
}
.rc-app-shell .rc-sidebar.sidebar,
.rc-mobile-sidebar {
width: 15rem;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
border-right: 1px solid var(--rc-border-default);
border-radius: 0;
background: var(--rc-bg-canvas);
box-shadow: none;
}
.rc-app-shell .sidebar__header,
.rc-mobile-sidebar .sidebar__header {
padding: var(--rc-space-2) var(--rc-space-4) var(--rc-space-2);
}
.rc-sidebar-brand {
min-height: 3rem;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
}
.rc-sidebar-title {
min-width: 0;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
}
.rc-sidebar-app-trigger.button {
display: grid;
width: var(--rc-sidebar-app-trigger-size);
height: var(--rc-sidebar-app-trigger-size);
min-width: var(--rc-sidebar-app-trigger-size);
max-width: var(--rc-sidebar-app-trigger-size);
flex: 0 0 var(--rc-sidebar-app-trigger-size);
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
background: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
}
@media (hover: hover) {
.rc-sidebar-app-trigger.button:hover,
.rc-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background: var(--accent-soft);
}
}
.rc-sidebar-app-trigger.button[data-pressed='true'],
.rc-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background: var(--accent-soft);
}
.rc-sidebar-app-popover {
width: min(45rem, calc(100vw - 2rem));
padding: 0;
}
.rc-sidebar-app-dialog {
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
outline: none;
}
.rc-sidebar-app-card {
min-width: 0;
cursor: pointer;
}
.rc-sidebar-app-card[aria-disabled='true'] {
cursor: default;
opacity: 0.6;
}
.rc-application-identity-well {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
flex: none;
overflow: hidden;
border-radius: var(--rc-radius-control);
}
.rc-application-identity-avatar.avatar {
width: 100%;
height: 100%;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-sidebar-app-logo,
.rc-sidebar-app-initial {
width: 2rem;
height: 2rem;
border-radius: var(--rc-radius-container);
object-fit: contain;
}
.rc-sidebar-app-initial {
display: grid;
place-items: center;
color: var(--foreground);
background: var(--default);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
padding: 0 var(--rc-space-3);
}
.rc-app-shell .sidebar__group,
.rc-mobile-sidebar .sidebar__group {
padding: 0;
}
.rc-app-shell .sidebar__group + .sidebar__group,
.rc-mobile-sidebar .sidebar__group + .sidebar__group {
margin-top: var(--rc-space-2);
}
.rc-app-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label {
padding: 0.375rem var(--rc-space-2);
color: var(--rc-text-muted);
}
.rc-app-shell .sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item-content {
min-height: 2.25rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
}
.rc-app-shell .sidebar__menu-icon,
.rc-app-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-label {
color: inherit;
}
.rc-app-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
color: var(--foreground);
background: var(--default);
}
.rc-app-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-app-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content {
color: var(--foreground);
background: var(--default);
}
.rc-app-shell .sidebar__footer,
.rc-mobile-sidebar .sidebar__footer {
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
}
.rc-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.375rem 0.5rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
background: transparent;
text-align: left;
}
.rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
.rc-sidebar-avatar {
width: 2rem;
height: 2rem;
min-width: 2rem;
flex: 0 0 2rem;
background: var(--default);
}
.rc-sidebar-user-copy {
gap: 0.125rem;
line-height: 1;
}
.rc-sidebar-user-menu-item {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--rc-space-2);
}
.rc-app-main.sidebar__main {
height: 100dvh;
min-height: 0;
max-height: 100dvh;
margin: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--rc-bg-canvas);
}
.dark .rc-app-main.sidebar__main,
[data-theme='dark'] .rc-app-main.sidebar__main,
.dark .app-shell-content-scroll,
[data-theme='dark'] .app-shell-content-scroll {
background: var(--rc-bg-canvas);
}
.rc-navbar {
flex: none;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
border: 0;
background: transparent;
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-trigger-size);
min-width: var(--rc-navbar-trigger-size);
max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size);
padding: 0;
}
.rc-navbar-icon-button.button,
.rc-navbar-icon-button.toggle-button {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
padding: 0;
}
@media (max-width: 768px) {
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex-basis: var(--rc-navbar-icon-button-size);
}
.rc-app-shell > .sidebar__offcanvas-wrapper {
display: none;
}
.rc-mobile-sidebar {
width: 100%;
border-right: 0;
}
}
@media (min-width: 769px) {
.rc-app-shell .rc-sidebar.sidebar {
display: flex;
}
}
.rc-login-page {
display: grid;
min-height: 100dvh;
place-items: center;
padding: var(--rc-space-6);
background: var(--app-background-gradient);
}
.rc-login-shell {
display: grid;
width: min(100%, var(--rc-login-panel-width));
gap: var(--rc-login-brand-gap);
}
.rc-login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--rc-space-3);
color: var(--rc-text-primary);
}
.rc-login-brand img {
width: auto;
max-width: 8rem;
height: 1.5rem;
filter: none;
object-fit: contain;
}
.rc-login-brand strong {
font-size: var(--rc-login-brand-size);
font-weight: var(--rc-font-semibold);
}
.rc-login-card.card {
width: 100%;
border-radius: var(--rc-radius-overlay);
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
}
.rc-login-form {
display: grid;
gap: var(--rc-space-5);
}
@media (max-width: 480px) {
.rc-login-page {
align-items: start;
padding: 12vh var(--rc-space-4) var(--rc-space-4);
}
.rc-login-brand {
align-items: flex-start;
flex-direction: column;
}
}
/* approved Xinyuan baseline Sidebar geometry and typography contract. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
width: 15rem;
height: 100%;
max-height: 100%;
margin: 0;
overflow: hidden;
border: 0;
border-right: 1px solid var(--rc-border-default);
border-radius: 0;
color: var(--foreground);
background: var(--rc-bg-canvas);
box-shadow: none;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
.template-dashboard-shell .sidebar__header,
.rc-mobile-sidebar .sidebar__header {
padding: 6px 1rem 0.5rem;
}
.app-sidebar-brand {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
}
.app-sidebar-brand-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.app-sidebar-brand-mark {
display: grid;
width: 1.75rem;
height: 1.75rem;
flex: none;
place-items: center;
overflow: hidden;
}
.app-sidebar-brand-mark img {
grid-area: 1 / 1;
display: block;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
.app-sidebar-title {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
letter-spacing: 0;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-sidebar-app-switcher {
flex: none;
}
.app-sidebar-app-trigger.button {
display: grid;
width: var(--rc-sidebar-app-trigger-size);
height: var(--rc-sidebar-app-trigger-size);
min-width: var(--rc-sidebar-app-trigger-size);
max-width: var(--rc-sidebar-app-trigger-size);
flex: none;
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
}
.app-sidebar-app-trigger .anticon {
display: grid;
width: var(--rc-sidebar-app-trigger-icon-size);
height: var(--rc-sidebar-app-trigger-icon-size);
place-items: center;
line-height: 1;
}
.app-sidebar-app-trigger .anticon > svg {
display: block;
width: 100%;
height: 100%;
}
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
}
.app-sidebar-app-dialog {
overflow: hidden;
outline: none;
}
.rc-dialog-panel--xs,
.rc-dialog-panel--sm,
.rc-dialog-panel--md,
.rc-dialog-panel--lg,
.rc-dialog-panel--xl,
.rc-dialog-panel--2xl,
.rc-dialog-panel--3xl,
.rc-dialog-panel--4xl,
.rc-dialog-panel--5xl {
width: min(calc(100vw - 5rem), var(--rc-dialog-panel-width));
max-width: var(--rc-dialog-panel-width);
}
.rc-dialog-panel--xs {
--rc-dialog-panel-width: var(--rc-dialog-width-xs);
}
.rc-dialog-panel--sm {
--rc-dialog-panel-width: var(--rc-dialog-width-sm);
}
.rc-dialog-panel--md {
--rc-dialog-panel-width: var(--rc-dialog-width-md);
}
.rc-dialog-panel--lg {
--rc-dialog-panel-width: var(--rc-dialog-width-lg);
}
.rc-dialog-panel--xl {
--rc-dialog-panel-width: var(--rc-dialog-width-xl);
}
.rc-dialog-panel--2xl {
--rc-dialog-panel-width: var(--rc-dialog-width-2xl);
}
.rc-dialog-panel--3xl {
--rc-dialog-panel-width: var(--rc-dialog-width-3xl);
}
.rc-dialog-panel--4xl {
--rc-dialog-panel-width: var(--rc-dialog-width-4xl);
}
.rc-dialog-panel--5xl {
--rc-dialog-panel-width: var(--rc-dialog-width-5xl);
}
@media (max-width: 640px) {
.rc-dialog-panel--xs,
.rc-dialog-panel--sm,
.rc-dialog-panel--md,
.rc-dialog-panel--lg,
.rc-dialog-panel--xl,
.rc-dialog-panel--2xl,
.rc-dialog-panel--3xl,
.rc-dialog-panel--4xl,
.rc-dialog-panel--5xl {
width: calc(100vw - 2rem);
max-width: calc(100vw - 2rem);
}
}
@media (max-width: 767px) {
.rc-dialog-footer.modal__footer {
flex-direction: column-reverse;
align-items: stretch;
}
.rc-dialog-footer.modal__footer > .button {
width: 100%;
}
}
.app-sidebar-app-scroll {
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
}
.template-dashboard-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
padding-inline: 0.75rem;
}
.template-dashboard-shell .sidebar__group,
.rc-mobile-sidebar .sidebar__group {
padding: 0;
}
.template-dashboard-shell .sidebar__group + .sidebar__group,
.rc-mobile-sidebar .sidebar__group + .sidebar__group {
margin-top: 0.5rem;
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label {
padding: 0.375rem 0.5rem;
color: var(--rc-text-muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
letter-spacing: 0;
}
.template-dashboard-shell .sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item-content {
min-height: 2.25rem;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
font-weight: 400;
}
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon {
width: 1.25rem;
height: 1.25rem;
color: var(--muted);
}
.template-dashboard-shell .sidebar__menu-icon svg,
.rc-mobile-sidebar .sidebar__menu-icon svg {
width: 1rem;
height: 1rem;
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label {
color: var(--foreground);
font-size: 0.875rem;
font-weight: inherit;
line-height: 1.25rem;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content {
color: var(--foreground);
background: var(--default);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
font-weight: 500;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
:is(.sidebar__menu-icon, .sidebar__menu-label),
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
:is(.sidebar__menu-icon, .sidebar__menu-label) {
color: var(--foreground);
}
.template-dashboard-shell .sidebar__footer,
.rc-mobile-sidebar .sidebar__footer {
padding: 0.5rem 0.75rem 1rem;
}
.app-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: flex-start;
gap: 0.625rem;
padding: 0.375rem 0.5rem !important;
border-radius: calc(var(--radius) * 2) !important;
color: var(--foreground);
background: transparent;
text-align: left;
}
@media (hover: hover) {
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default);
}
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
.app-sidebar-user-avatar {
width: 2rem;
height: 2rem;
min-width: 2rem;
flex: 0 0 2rem;
background: var(--default);
}
.app-sidebar-user-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
line-height: 1;
}
.app-sidebar-user-name,
.app-sidebar-user-description {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-sidebar-user-name {
color: var(--foreground);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.125rem;
}
.app-sidebar-user-description {
color: var(--muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
}
@media (max-width: 768px) {
.rc-mobile-sidebar.app-sidebar-glass {
width: 100%;
border-right: 0;
}
}
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[data-theme='light'],
[data-theme='default'] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90% 0.0045 27.03);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.009 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.009 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.1% 0.0045 27.03);
--segment: oklch(100% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12% 0.0045 27.03);
--border: oklch(28% 0.0045 27.03);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.5% 0.009 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.5% 0.009 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.5% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.009 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.7% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.14 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
}
:root,
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 12%,
transparent
);
--rc-text-primary: var(--foreground);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 72%,
var(--background)
);
--rc-text-muted: var(--muted);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
--rc-font-semibold: 600;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
--rc-space-4: 1rem;
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
--rc-page-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
--rc-motion-fast: 120ms;
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--rc-button-radius: 1.5rem;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
/* Shared modal width ladder from the Xinyuan overlay contract. */
--rc-dialog-width-xs: 20rem;
--rc-dialog-width-sm: 24rem;
--rc-dialog-width-md: 30rem;
--rc-dialog-width-lg: 40rem;
--rc-dialog-width-xl: 46rem;
--rc-dialog-width-2xl: 52.5rem;
--rc-dialog-width-3xl: 60rem;
--rc-dialog-width-4xl: 68rem;
--rc-dialog-width-5xl: 76rem;
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%), 0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%), 0 4px 12px rgb(0 0 0 / 18%);
}
import {
DatabaseOutlined,
DownloadOutlined,
FileExcelOutlined,
FileMarkdownOutlined,
FileWordOutlined,
InboxOutlined,
ReloadOutlined,
SettingOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { UploadOutlined } from '@ant-design/icons';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Button,
Collapse,
Divider,
Empty,
Flex,
Form,
Input,
InputNumber,
Chip,
Description,
Disclosure,
Dropdown,
Label,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload,
message,
} from 'antd';
import { createStyles } from 'antd-style';
NumberField,
Surface,
TextArea,
TextField,
toast,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { AUDIT_MATCH_THRESHOLD } from './constants';
import { groupAuditRecords } from './engine';
......@@ -42,267 +30,6 @@ import {
parseAuditSummary,
} from './export';
const useAuditStyles = createStyles(({ token, css }) => ({
knowledgeBar: css`
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 64px;
padding: 10px 12px 10px 18px;
border: 1px solid rgba(125, 211, 252, 0.18);
border-radius: 20px;
background: linear-gradient(
135deg,
rgba(2, 6, 23, 0.88) 0%,
rgba(15, 23, 42, 0.76) 56%,
rgba(8, 47, 73, 0.48) 100%
);
box-shadow: 0 22px 52px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0.12),
transparent 36%
),
radial-gradient(
circle at 88% 50%,
rgba(99, 102, 241, 0.18),
transparent 30%
);
}
> * {
position: relative;
z-index: 1;
}
.ant-btn {
min-width: 44px;
min-height: 44px;
border-radius: 16px;
border-color: rgba(125, 211, 252, 0.18);
background: rgba(2, 6, 23, 0.56);
color: #cbd5e1;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-btn:hover {
border-color: rgba(34, 211, 238, 0.42) !important;
color: #67e8f9 !important;
background: rgba(8, 47, 73, 0.68) !important;
}
.ant-btn-primary {
height: 44px;
padding-inline: 18px;
border: 1px solid rgba(186, 230, 253, 0.28) !important;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%) !important;
color: #ffffff !important;
box-shadow: 0 16px 34px rgba(14, 165, 233, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
}
@media (max-width: 575px) {
align-items: flex-start;
flex-direction: column;
padding: 14px;
}
`,
knowledgeMeta: css`
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
color: #94a3b8;
font-size: 15px;
.anticon {
color: #67e8f9;
font-size: 18px;
filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.38));
}
strong {
color: #e5eefb;
margin-right: 2px;
font-size: 16px;
letter-spacing: 0.01em;
}
`,
result: css`
width: 100%;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid ${token.colorBorderSecondary};
`,
resultHeader: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
@media (max-width: 575px) {
flex-direction: column;
}
`,
stats: css`
color: ${token.colorTextSecondary};
font-size: 13px;
line-height: 1.6;
font-variant-numeric: tabular-nums;
`,
checklistTitle: css`
margin: 0 0 4px !important;
font-size: 22px !important;
line-height: 1.4 !important;
`,
checklistMeta: css`
display: block;
margin-bottom: 4px;
color: ${token.colorTextSecondary};
font-size: 13px;
font-variant-numeric: tabular-nums;
`,
checklistDescription: css`
margin: 0 0 18px !important;
color: ${token.colorText};
line-height: 1.7;
`,
checklistSection: css`
margin: 0 0 18px;
h4 {
margin: 0 0 6px !important;
font-size: 16px !important;
line-height: 1.5 !important;
}
.ant-typography {
margin-bottom: 0;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
`,
businessSection: css`
padding: 14px 0 4px;
border-top: 1px solid ${token.colorBorderSecondary};
&:first-of-type {
border-top: 0;
padding-top: 2px;
}
`,
businessTitle: css`
margin: 0 0 12px !important;
font-size: 16px !important;
line-height: 1.5 !important;
`,
levelOne: css`
margin: 0 0 16px 12px;
@media (max-width: 575px) {
margin-left: 0;
}
`,
levelOneTitle: css`
display: block;
margin-bottom: 8px;
color: ${token.colorText};
font-size: 14px;
`,
detailCollapse: css`
border-radius: 8px;
overflow: hidden;
background: ${token.colorFillAlter};
.ant-collapse-header {
min-height: 44px;
align-items: center !important;
padding: 10px 12px !important;
}
.ant-collapse-content-box {
padding: 0 12px 12px !important;
}
`,
risk: css`
padding: 12px 0 12px 12px;
border-left: 3px solid ${token.colorPrimaryBorder};
border-bottom: 1px solid ${token.colorBorderSecondary};
&:last-child {
border-bottom: 0;
}
`,
riskTitle: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
`,
riskDescription: css`
margin: 0 0 10px !important;
color: ${token.colorText};
line-height: 1.7;
overflow-wrap: anywhere;
`,
riskLine: css`
margin: 0 0 6px !important;
color: ${token.colorText};
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
&:last-child {
margin-bottom: 0 !important;
}
`,
exportBar: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: 14px;
padding: 10px 12px;
border: 1px solid rgba(125, 211, 252, 0.16);
border-radius: 16px;
background: rgba(2, 6, 23, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
.ant-typography {
color: #94a3b8 !important;
}
.ant-btn-primary {
border: 1px solid rgba(186, 230, 253, 0.26) !important;
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
box-shadow: 0 12px 28px rgba(14, 165, 233, 0.22) !important;
}
`,
importPreview: css`
margin-top: 16px;
`,
importErrors: css`
max-height: 120px;
overflow: auto;
margin-top: 12px;
`,
}));
function downloadText(content, fileName) {
const url = URL.createObjectURL(
new Blob([content], { type: 'text/markdown;charset=utf-8' }),
......@@ -310,10 +37,8 @@ function downloadText(content, fileName) {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
URL.revokeObjectURL(url);
}
function formatChecklistDate(value) {
......@@ -321,144 +46,196 @@ function formatChecklistDate(value) {
const pad = (number) => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function AuditKnowledgeBar({
count,
export function AuditKnowledgeActions({
fileName,
loading,
onOpen,
onOpenPrompt,
onRefresh,
}) {
const { styles } = useAuditStyles();
const handleAction = (key) => {
if (key === 'prompt') onOpenPrompt?.();
if (key === 'refresh') onRefresh?.();
if (key === 'excel') onOpen?.();
};
return (
<div className={styles.knowledgeBar}>
<div className={styles.knowledgeMeta}>
<DatabaseOutlined style={{ marginRight: 8 }} />
<strong>审计风险点知识库</strong>
{loading
? '正在读取 Excel'
: fileName
? `${fileName} · 共 ${count} 条有效风险点`
: '尚未配置 Excel'}
</div>
<Space wrap>
<Tooltip title="配置审计提示词">
<Button
aria-label="配置审计提示词"
icon={<SettingOutlined />}
onClick={onOpenPrompt}
/>
</Tooltip>
<Tooltip title="刷新知识库">
<>
<div className="hidden items-center gap-2 md:flex">
<Button size="sm" variant="tertiary" onPress={onOpenPrompt}>
回答规则
</Button>
{fileName ? (
<Button
aria-label="刷新知识库"
icon={<ReloadOutlined />}
loading={loading}
onClick={onRefresh}
/>
</Tooltip>
<Button icon={<UploadOutlined />} type="primary" onClick={onOpen}>
配置 Excel
isPending={loading}
size="sm"
variant="tertiary"
onPress={onRefresh}
>
重新加载
</Button>
) : null}
<Button size="sm" variant="primary" onPress={onOpen}>
{fileName ? '更换 Excel' : '配置 Excel'}
</Button>
</Space>
</div>
<div className="md:hidden">
<Dropdown>
<Dropdown.Trigger size="sm" variant="secondary">
知识库
</Dropdown.Trigger>
<Dropdown.Popover placement="bottom end">
<Dropdown.Menu aria-label="审计知识库操作" onAction={handleAction}>
<Dropdown.Item id="prompt" textValue="回答规则">
<Label>回答规则</Label>
</Dropdown.Item>
{fileName ? (
<Dropdown.Item
id="refresh"
isDisabled={loading}
textValue="重新加载"
>
<Label>重新加载</Label>
</Dropdown.Item>
) : null}
<Dropdown.Item
id="excel"
textValue={fileName ? '更换 Excel' : '配置 Excel'}
>
<Label>{fileName ? '更换 Excel' : '配置 Excel'}</Label>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</>
);
}
export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) {
const [form] = Form.useForm();
const [prompt, setPrompt] = useState('');
const [context, setContext] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
form.setFieldsValue({
prompt: value?.prompt || '',
context: value?.context || '',
});
}
}, [form, open, value]);
if (!open) return;
setPrompt(value?.prompt || '');
setContext(value?.context || '');
}, [open, value]);
const handleSave = async () => {
let values;
try {
values = await form.validateFields();
} catch {
return;
}
if (!prompt.trim() || !context.trim()) return;
setSaving(true);
try {
await onSave?.(values);
message.success('审计提示词已保存');
await onSave?.({ context, prompt });
toast.success('审计提示词已保存');
onCancel?.();
} catch (error) {
message.error(`提示词保存失败:${error.message || '未知错误'}`);
toast.danger(`提示词保存失败:${error.message || '未知错误'}`);
} finally {
setSaving(false);
}
};
return (
<Modal
title="审计提示词配置"
open={open}
width={760}
confirmLoading={saving}
maskClosable={!saving}
closable={!saving}
okText="保存"
cancelText="取消"
onOk={handleSave}
onCancel={saving ? undefined : onCancel}
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isDismissable={!saving}
isOpen={open}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen && !saving) onCancel?.();
}}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
label="主提示词"
name="prompt"
rules={[{ required: true, message: '请输入主提示词' }]}
<Modal.Container placement="center" scroll="inside">
<Modal.Dialog className="rc-dialog-panel--4xl">
<Modal.CloseTrigger aria-label="关闭" />
<Modal.Header>
<Modal.Heading>审计提示词配置</Modal.Heading>
</Modal.Header>
<Modal.Body className="grid gap-6">
<TextField
fullWidth
isRequired
value={prompt}
onChange={setPrompt}
>
<Input.TextArea rows={7} showCount maxLength={6000} />
</Form.Item>
<Form.Item
label="业务规则与约束"
name="context"
rules={[{ required: true, message: '请输入业务规则与约束' }]}
<Label>主提示词</Label>
<TextArea maxLength={6000} rows={7} variant="secondary" />
</TextField>
<TextField
fullWidth
isRequired
value={context}
onChange={setContext}
>
<Label>业务规则与约束</Label>
<TextArea maxLength={10000} rows={9} variant="secondary" />
</TextField>
</Modal.Body>
<Modal.Footer className="rc-dialog-footer">
<Button
isDisabled={saving}
variant="secondary"
onPress={onCancel}
>
取消
</Button>
<Button
isDisabled={!prompt.trim() || !context.trim()}
isPending={saving}
variant="primary"
onPress={handleSave}
>
<Input.TextArea rows={9} showCount maxLength={10000} />
</Form.Item>
</Form>
</Modal>
保存
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
function RiskDetail({ risk, index, styles }) {
const detail = (label, value) => (
<Typography.Paragraph className={styles.riskLine}>
<Typography.Text strong>{label}:</Typography.Text>
{value || '(无)'}
</Typography.Paragraph>
);
function RiskDetail({ risk, index }) {
const details = [
['匹配度', `${risk.matchScore}分(${risk.matchLevel || '未分级'})`],
['具体风险点描述', risk.riskDescription],
['法规依据', risk.regulationBasis],
['风险点来源', risk.riskSource],
['风险等级', risk.riskLevel],
];
return (
<div className={styles.risk}>
<div className={styles.riskTitle}>
<Typography.Text strong>
<Surface className="grid gap-3 p-4" variant="tertiary">
<strong>
风险点 {index + 1}(源文件第 {risk.sourceRow || '未知'} 行)
</Typography.Text>
</div>
{detail(
'匹配度',
`${risk.matchScore}分(${risk.matchLevel || '未分级'})`,
)}
{detail('具体风险点描述', risk.riskDescription)}
{detail('法规依据', risk.regulationBasis)}
{detail('风险点来源', risk.riskSource)}
{detail('风险等级', risk.riskLevel)}
</strong>
<dl className="grid gap-2">
{details.map(([label, detail]) => (
<div
className="grid gap-1 sm:grid-cols-[8rem_minmax(0,1fr)]"
key={label}
>
<dt>{label}</dt>
<dd>{detail || '(无)'}</dd>
</div>
))}
</dl>
</Surface>
);
}
function SummarySection({ field, summary }) {
return (
<section className="grid gap-2">
<h4>{field}</h4>
<p>{summary[field]}</p>
</section>
);
}
......@@ -467,7 +244,6 @@ export function AuditChecklistResult({
aiContent = '',
allowWordExport = false,
}) {
const { styles } = useAuditStyles();
const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD),
);
......@@ -490,157 +266,164 @@ export function AuditChecklistResult({
() => parseAuditSummary(aiContent, result.project),
[aiContent, result.project],
);
const summarySection = (field) => (
<section className={styles.checklistSection}>
<Typography.Title level={4}>{field}</Typography.Title>
<Typography.Paragraph>{summary[field]}</Typography.Paragraph>
</section>
);
const handleWordExport = async () => {
setWordLoading(true);
try {
await exportAuditWord(result, aiContent, threshold);
} catch (error) {
message.error(`Word 导出失败:${error.message || '未知错误'}`);
toast.danger(`Word 导出失败:${error.message || '未知错误'}`);
} finally {
setWordLoading(false);
}
};
return (
<section className={styles.result} aria-label="审计风险点结果">
{!isQa && (
<section aria-label="审计风险点结果" className="grid gap-6">
{!isQa ? (
<>
<Typography.Title level={3} className={styles.checklistTitle}>
智能审计清单
</Typography.Title>
<Typography.Text className={styles.checklistMeta}>
生成时间:{formatChecklistDate(result.generatedAt)}
</Typography.Text>
<Typography.Paragraph className={styles.checklistDescription}>
<Typography.Text strong>审计项目描述:</Typography.Text>
<div className="grid gap-1">
<h3>智能审计清单</h3>
<p>生成时间:{formatChecklistDate(result.generatedAt)}</p>
<p>
<strong>审计项目描述:</strong>
{result.project}
</Typography.Paragraph>
{summarySection('审计项目')}
{summarySection('审计对象')}
</p>
</div>
<SummarySection field="审计项目" summary={summary} />
<SummarySection field="审计对象" summary={summary} />
</>
)}
<div className={styles.resultHeader}>
) : null}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{isQa ? '引用风险点' : '审计内容'}
</Typography.Title>
<div className={styles.stats}>
<h4>{isQa ? '引用风险点' : '审计内容'}</h4>
<p className="mt-1">
知识库 {result.totalCount} 条 · 全量评分 {result.scoredCount} 条 ·
当前显示 {visibleRecords.length} 条
{result.refined ? ` · 已应用调整“${result.request}”` : ''}
</p>
</div>
</div>
<Tag color={visibleRecords.length ? 'processing' : 'warning'}>
<Chip color={visibleRecords.length ? 'accent' : 'warning'} size="sm">
匹配度 ≥ {threshold}
</Tag>
</Chip>
</div>
{!visibleRecords.length ? (
<Alert
type="warning"
showIcon
message="当前阈值下未命中风险点"
description="请补充更明确的业务板块、项目类别或风险点来源,或在消息中指定更低的匹配度阈值。"
/>
<Alert status="warning">
<Alert.Content>
<Alert.Title>当前阈值下未命中风险点</Alert.Title>
<Alert.Description>
请补充更明确的业务板块、项目类别或风险点来源,或在消息中指定更低的匹配度阈值。
</Alert.Description>
</Alert.Content>
</Alert>
) : (
groups.map((business, businessIndex) => (
<section
className={styles.businessSection}
<div className="grid gap-4">
{groups.map((business, businessIndex) => (
<Surface
className="grid gap-4 p-5"
key={business.businessSegment}
variant="secondary"
>
<Typography.Title level={5} className={styles.businessTitle}>
<h5>
业务板块 {businessIndex + 1}:{business.businessSegment}
</Typography.Title>
</h5>
{business.level1Projects.map((level1, level1Index) => (
<div
className={styles.levelOne}
className="grid gap-3"
key={`${business.businessSegment}-${level1.level1Project}`}
>
<Typography.Text strong className={styles.levelOneTitle}>
<strong>
一级项目 {level1Index + 1}:{level1.level1Project}
</Typography.Text>
<Collapse
className={styles.detailCollapse}
bordered={false}
items={level1.level2Projects.map((level2, level2Index) => ({
key: `${level1.level1Project}-${level2.level2Project}`,
label: `二级项目 ${level2Index + 1}:${
level2.level2Project
}`,
extra: `${level2.risks.length} 个风险点`,
children: level2.risks.map((risk, riskIndex) => (
</strong>
{level1.level2Projects.map((level2, level2Index) => (
<Disclosure
key={`${level1.level1Project}-${level2.level2Project}`}
variant="secondary"
>
<Disclosure.Heading>
<Disclosure.Trigger>
<Disclosure.Indicator />
二级项目 {level2Index + 1}:{level2.level2Project}
<Chip size="sm">{level2.risks.length} 个风险点</Chip>
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body className="grid gap-3">
{level2.risks.map((risk, riskIndex) => (
<RiskDetail
index={riskIndex}
key={`${risk.id || risk.sourceRow}-${riskIndex}`}
risk={risk}
index={riskIndex}
styles={styles}
/>
)),
}))}
/>
))}
</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
))}
</div>
))}
</section>
))
</Surface>
))}
</div>
)}
{!isQa && (
{!isQa ? (
<>
{summarySection('审计方法')}
{summarySection('审计人员')}
{summarySection('审计时间')}
{summarySection('注意事项')}
<SummarySection field="审计方法" summary={summary} />
<SummarySection field="审计人员" summary={summary} />
<SummarySection field="审计时间" summary={summary} />
<SummarySection field="注意事项" summary={summary} />
</>
)}
{!isQa && visibleRecords.length > 0 && (
<div className={styles.exportBar}>
<Space wrap>
<Typography.Text>导出最低匹配度</Typography.Text>
<InputNumber
aria-label="导出最低匹配度"
min={Number(result.threshold || 0)}
max={100}
) : null}
{!isQa && visibleRecords.length > 0 ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<NumberField
className="w-48"
label="导出最低匹配度"
maxValue={100}
minValue={Number(result.threshold || 0)}
step={5}
value={threshold}
variant="secondary"
onChange={(value) =>
setThreshold(Number(value || result.threshold))
}
/>
</Space>
<Space wrap>
>
<Label>导出最低匹配度</Label>
<NumberField.Group>
<NumberField.DecrementButton />
<NumberField.Input />
<NumberField.IncrementButton />
</NumberField.Group>
</NumberField>
<div className="flex flex-wrap gap-2">
<Button
icon={<FileMarkdownOutlined />}
onClick={() => exportAuditMarkdown(result, aiContent, threshold)}
variant="secondary"
onPress={() => exportAuditMarkdown(result, aiContent, threshold)}
>
Markdown
</Button>
<Button
icon={<FileExcelOutlined />}
onClick={() => exportAuditExcel(result, aiContent, threshold)}
variant="secondary"
onPress={() => exportAuditExcel(result, aiContent, threshold)}
>
Excel
</Button>
{allowWordExport && (
{allowWordExport ? (
<Button
type="primary"
icon={<FileWordOutlined />}
loading={wordLoading}
onClick={handleWordExport}
isPending={wordLoading}
variant="primary"
onPress={handleWordExport}
>
Word
</Button>
)}
</Space>
) : null}
</div>
)}
</div>
) : null}
</section>
);
}
......@@ -651,7 +434,6 @@ export function AuditWordDownload({
question = '',
title = '审计知识库问答',
}) {
const { styles } = useAuditStyles();
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
......@@ -659,36 +441,67 @@ export function AuditWordDownload({
try {
await exportGroundedAuditWord({ records, aiContent, question, title });
} catch (error) {
message.error(`Word 导出失败:${error.message || '未知错误'}`);
toast.danger(`Word 导出失败:${error.message || '未知错误'}`);
} finally {
setLoading(false);
}
};
return (
<div className={styles.exportBar}>
<Typography.Text type="secondary">
知识库原文 {records.length} 条
</Typography.Text>
<Button
type="primary"
icon={<FileWordOutlined />}
loading={loading}
onClick={handleDownload}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span>知识库原文 {records.length} 条</span>
<Button isPending={loading} variant="primary" onPress={handleDownload}>
下载 Word
</Button>
</div>
);
}
const PREVIEW_COLUMNS = [
{
accessorKey: 'businessSegment',
header: '业务板块',
id: 'businessSegment',
minWidth: 130,
},
{
accessorKey: 'level1Project',
header: '一级项目',
id: 'level1Project',
minWidth: 150,
},
{
accessorKey: 'level2Project',
header: '二级项目',
id: 'level2Project',
minWidth: 160,
},
{
accessorKey: 'riskDescription',
header: '具体风险点描述',
id: 'riskDescription',
minWidth: 320,
},
{
accessorKey: 'riskLevel',
header: '风险等级',
id: 'riskLevel',
minWidth: 100,
},
{
accessorKey: 'sourceRow',
header: '来源行号',
id: 'sourceRow',
minWidth: 100,
},
];
export function AuditKnowledgeImportDialog({
open,
onSave,
onCancel,
onImported,
}) {
const { styles } = useAuditStyles();
const [parsing, setParsing] = useState(false);
const [parsed, setParsed] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
......@@ -702,20 +515,27 @@ export function AuditKnowledgeImportDialog({
}, [open]);
const handleFile = async (file) => {
if (!file) return;
setParsing(true);
try {
const result = await parseAuditExcel(file);
setParsed(result);
setSelectedFile(file);
if (!result.records.length) message.warning('Excel 中未识别到有效风险点');
if (!result.records.length) toast.warning('Excel 中未识别到有效风险点');
} catch (error) {
setParsed(null);
setSelectedFile(null);
message.error(`Excel 解析失败:${error.message || '文件格式异常'}`);
toast.danger(`Excel 解析失败:${error.message || '文件格式异常'}`);
} finally {
setParsing(false);
}
return false;
};
const handleDrop = async (event) => {
const fileItem = Array.from(event?.items || []).find(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItem) await handleFile(await fileItem.getFile());
};
const handleSave = async () => {
......@@ -723,127 +543,157 @@ export function AuditKnowledgeImportDialog({
setImporting(true);
try {
const result = await onSave?.({ file: selectedFile, parsed });
message.success(
`Excel 已保存,共 ${parsed.records.length} 条有效风险点;新会话将重新读取该文件`,
);
toast.success(`Excel 已保存,共 ${parsed.records.length} 条有效风险点`);
await onImported?.(result);
onCancel?.();
} catch (error) {
message.error(`Excel 保存失败:${error.message || '未知错误'}`);
toast.danger(`Excel 保存失败:${error.message || '未知错误'}`);
} finally {
setImporting(false);
}
};
const columns = [
{ title: '业务板块', dataIndex: 'businessSegment', width: 130 },
{ title: '一级项目', dataIndex: 'level1Project', width: 150 },
{ title: '二级项目', dataIndex: 'level2Project', width: 160 },
{
title: '具体风险点描述',
dataIndex: 'riskDescription',
width: 320,
ellipsis: true,
},
{ title: '风险等级', dataIndex: 'riskLevel', width: 100 },
{ title: '来源行号', dataIndex: 'sourceRow', width: 100 },
];
return (
<Modal
title="配置审计知识库 Excel"
open={open}
width={920}
onCancel={onCancel}
maskClosable={!importing}
closable={!importing}
footer={[
<Button key="cancel" onClick={onCancel} disabled={importing}>
取消
</Button>,
<Button
key="markdown"
icon={<DownloadOutlined />}
disabled={!parsed?.records.length || importing}
onClick={() =>
downloadText(
parsed.markdown,
`${parsed.fileName.replace(/\.[^.]+$/, '')}.md`,
)
}
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isDismissable={!importing}
isOpen={open}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen && !importing) onCancel?.();
}}
>
下载 Markdown
</Button>,
<Button
key="import"
type="primary"
icon={<FileExcelOutlined />}
loading={importing}
disabled={!selectedFile || !parsed?.records.length}
onClick={handleSave}
>
保存 Excel 文件
</Button>,
]}
<Modal.Container placement="center" scroll="inside">
<Modal.Dialog className="rc-dialog-panel--4xl">
<Modal.CloseTrigger aria-label="关闭" />
<Modal.Header>
<div>
<Modal.Heading>配置审计知识库 Excel</Modal.Heading>
<Description>
导入文件后预览有效风险点,再保存到知识库。
</Description>
</div>
</Modal.Header>
<Modal.Body className="grid gap-5">
<DropZone>
<DropZone.Area
aria-label="选择审计知识库 Excel"
isDisabled={parsing || importing}
onDrop={handleDrop}
>
<Upload.Dragger
<DropZone.Input
accept=".xlsx,.xls"
maxCount={1}
showUploadList={false}
beforeUpload={handleFile}
disabled={parsing || importing}
onSelect={(files) => handleFile(files?.[0])}
/>
<DropZone.Icon>
<UploadOutlined />
</DropZone.Icon>
<DropZone.Label>
{parsing
? '正在解析 Excel'
: selectedFile?.name || '将 Excel 文件拖到此处'}
</DropZone.Label>
<DropZone.Description>
支持 .xlsx 和 .xls 文件
</DropZone.Description>
<DropZone.Trigger isDisabled={parsing || importing}>
选择文件
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
{parsed ? (
<div className="grid min-w-0 gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<strong>{parsed.fileName}</strong>
<div className="flex flex-wrap gap-2">
<Chip color="success" size="sm">
有效 {parsed.records.length}
</Chip>
<Chip size="sm">重复行 {parsed.duplicateRows}</Chip>
<Chip
color={parsed.errors.length ? 'warning' : 'default'}
size="sm"
>
<p className="ant-upload-drag-icon">
{parsing ? <ReloadOutlined spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">
{parsing ? '正在解析 Excel' : '选择或拖入 Excel 文件'}
</p>
</Upload.Dragger>
{parsed && (
<div className={styles.importPreview}>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Typography.Text strong>{parsed.fileName}</Typography.Text>
<Space wrap>
<Tag color="success">有效 {parsed.records.length}</Tag>
<Tag>重复行 {parsed.duplicateRows}</Tag>
<Tag color={parsed.errors.length ? 'warning' : 'default'}>
异常 {parsed.errors.length}
</Tag>
</Space>
</Flex>
<Divider style={{ margin: '12px 0' }} />
{parsed.records.length ? (
<Table
size="small"
rowKey={(record) => `${record.sheetName}-${record.sourceRow}`}
columns={columns}
dataSource={parsed.records.slice(0, 20)}
pagination={false}
scroll={{ x: 960, y: 280 }}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Chip>
</div>
</div>
<DataGrid
aria-label="审计知识库导入预览"
columns={PREVIEW_COLUMNS}
contentClassName="min-w-[960px]"
data={parsed.records.slice(0, 20)}
getRowId={(record) =>
`${record.sheetName}-${record.sourceRow}`
}
renderEmptyState={() => (
<EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Title>
未识别到有效风险点
</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
)}
{parsed.errors.length > 0 && (
<div className={styles.importErrors}>
<Alert
type="warning"
showIcon
message={`${parsed.errors.length} 行未导入`}
description={parsed.errors
scrollContainerClassName="max-h-[280px] overflow-auto"
variant="secondary"
/>
{parsed.errors.length ? (
<Alert status="warning">
<Alert.Content>
<Alert.Title>
{parsed.errors.length} 行未导入
</Alert.Title>
<Alert.Description>
{parsed.errors
.slice(0, 8)
.map(
(error) =>
`${error.sheetName} 第 ${error.sourceRow} 行:${error.message}`,
)
.join(';')}
/>
</div>
)}
</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</div>
)}
</Modal>
) : null}
</Modal.Body>
<Modal.Footer className="rc-dialog-footer">
<Button
isDisabled={importing}
variant="secondary"
onPress={onCancel}
>
取消
</Button>
<Button
isDisabled={!parsed?.records.length || importing}
variant="secondary"
onPress={() =>
downloadText(
parsed.markdown,
`${parsed.fileName.replace(/\.[^.]+$/, '')}.md`,
)
}
>
下载 Markdown
</Button>
<Button
isDisabled={!selectedFile || !parsed?.records.length}
isPending={importing}
variant="primary"
onPress={handleSave}
>
保存 Excel 文件
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import { Outlet } from '@umijs/max';
import { App, ConfigProvider, theme } from 'antd';
import { createGlobalStyle } from 'antd-style';
const { defaultSeed, defaultAlgorithm } = theme;
const APP_FONT_FAMILY =
"'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif";
const GlobalStyle = createGlobalStyle`
:root {
--app-shell-bg:
radial-gradient(
circle at 18% 8%,
rgba(34, 211, 238, 0.16),
transparent 28%
),
radial-gradient(
circle at 84% 16%,
rgba(99, 102, 241, 0.2),
transparent 26%
),
radial-gradient(
circle at 74% 86%,
rgba(14, 165, 233, 0.13),
transparent 30%
),
linear-gradient(135deg, #050816 0%, #08111f 46%, #111827 100%);
--app-surface: rgba(15, 23, 42, 0.74);
--app-surface-strong: rgba(15, 23, 42, 0.94);
--app-brand: #22d3ee;
--app-brand-strong: #38bdf8;
--app-sidebar: rgba(2, 6, 23, 0.82);
--app-sidebar-soft: rgba(15, 23, 42, 0.84);
--app-border: rgba(125, 211, 252, 0.16);
--app-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
--app-shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.28);
--app-radius-xl: 32px;
--app-radius-lg: 24px;
--app-radius-md: 18px;
--app-radius-sm: 14px;
--app-content-width: 960px;
}
html,
body,
#root {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
body {
margin: 0;
background: var(--app-shell-bg);
color: #e5eefb;
font-family: ${APP_FONT_FAMILY};
}
* {
box-sizing: border-box;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: rgba(125, 211, 252, 0.36);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
.app-glass-card {
background: var(--app-surface);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow-soft);
backdrop-filter: blur(18px);
}
`;
export default () => {
const themeToken = defaultAlgorithm({
...defaultSeed,
colorPrimary: '#22d3ee',
colorInfo: '#38bdf8',
colorSuccess: '#1d9b62',
colorWarning: '#d97706',
colorError: '#dc2626',
colorBgBase: '#050816',
colorBgContainer: '#0f172a',
colorTextBase: '#e5eefb',
borderRadius: 18,
borderRadiusLG: 24,
borderRadiusSM: 14,
boxShadow:
'0 20px 50px rgba(15, 23, 42, 0.12), 0 6px 18px rgba(15, 23, 42, 0.06)',
boxShadowSecondary:
'0 12px 30px rgba(15, 23, 42, 0.08), 0 4px 12px rgba(15, 23, 42, 0.05)',
controlHeight: 44,
fontSize: 15,
});
return (
<>
<GlobalStyle />
<ConfigProvider
theme={{
token: themeToken,
components: {
Layout: {
bodyBg: 'transparent',
siderBg: 'transparent',
},
Button: {
borderRadius: 14,
controlHeight: 42,
primaryShadow: 'none',
},
Input: {
borderRadius: 14,
activeShadow: '0 0 0 3px rgba(200, 30, 58, 0.14)',
},
Modal: {
borderRadiusLG: 24,
},
Collapse: {
headerBg: 'transparent',
contentBg: 'transparent',
},
Drawer: {
colorBgElevated: '#0f172a',
},
Steps: {
colorPrimary: '#c81e3a',
},
},
}}
>
<App>
<Outlet />
</App>
</ConfigProvider>
</>
);
};
export default function RootLayout() {
return <Outlet />;
}
import { PageContainer } from '@ant-design/pro-components';
import { Button } from '@heroui/react';
import { Access, useAccess } from '@umijs/max';
import { Button } from 'antd';
const AccessPage = () => {
export default function AccessPage() {
const access = useAccess();
return (
<PageContainer
ghost
header={{
title: '权限示例',
}}
>
<main className="rc-app-page">
<h1 className="sr-only">权限示例</h1>
<Access accessible={access.canSeeAdmin}>
<Button>只有 Admin 可以看到这个按钮</Button>
<Button variant="primary">只有 Admin 可以看到这个按钮</Button>
</Access>
</PageContainer>
</main>
);
};
export default AccessPage;
}
import { useNavbarActions } from '@/components/AppShell';
import ChatWorkspace from '@/components/ChatWorkspace';
import { createSseStream, useChat, useChatAgent } from '@/utils/chatRuntime';
import {
BulbOutlined,
CloudUploadOutlined,
CopyOutlined,
DatabaseOutlined,
FileDoneOutlined,
FileSearchOutlined,
LoadingOutlined,
PaperClipOutlined,
ReloadOutlined,
SettingOutlined,
ToolOutlined,
} from '@ant-design/icons';
import {
Attachments,
Bubble,
Prompts,
Sender,
Welcome,
XStream,
useXAgent,
useXChat,
} from '@ant-design/x';
import { toast } from '@heroui/react';
import { useQuery } from '@umijs/max';
import {
Button,
Collapse,
Flex,
Space,
Tooltip,
Typography,
message,
theme,
} from 'antd';
import { createStyles } from 'antd-style';
import 'katex/dist/katex.min.css';
import _ from 'lodash';
import { marked } from 'marked';
import marketKatex from 'marked-katex-extension';
import {
forwardRef,
useCallback,
......@@ -48,7 +21,7 @@ import {
} from 'react';
import {
AuditChecklistResult,
AuditKnowledgeBar,
AuditKnowledgeActions,
AuditKnowledgeImportDialog,
AuditPromptConfigDialog,
AuditWordDownload,
......@@ -87,20 +60,6 @@ import {
// 导入发票助手组件
import InvoiceAssistant from './invoice';
const { success } = message;
marked.use(
marketKatex({
strict: false,
throwOnError: false,
nonStandard: true,
}),
);
const preprocessLaTeX = (content) => {
return content.replace(/\s\$\$\s/gi, '\n\n$$$$\n\n');
};
const SYSTEM_PROMPT = `你是一个有用的的智能助手。
如果用户提供了上下文,请根据上下文回答问题,不要试图创造一个答案。如果你不知道答案,请回答你只能根据上下文的内容回答问题。`;
......@@ -266,615 +225,8 @@ function getConversationTopic(metadata = {}) {
return null;
}
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
height: 100%;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 24px 20px;
background: transparent;
overflow: hidden;
${responsive.xs} {
padding: 12px;
gap: 10px;
}
`,
chatPrompt: css`
width: 100%;
min-width: 0;
overflow: hidden;
.ant-prompts,
.ant-prompts-list,
.ant-prompts-group,
.ant-prompts-group-content {
width: 100%;
min-width: 0;
}
& > .ant-prompts-list,
.ant-prompts-item-has-nest {
height: 100%;
}
.ant-prompts-item-has-nest {
position: relative;
display: flex !important;
width: 100% !important;
min-width: 0 !important;
overflow: hidden;
flex-direction: column !important;
border-radius: 24px !important;
border: 1px solid rgba(125, 211, 252, 0.18) !important;
background: linear-gradient(
135deg,
rgba(15, 23, 42, 0.88) 0%,
rgba(15, 23, 42, 0.68) 48%,
rgba(30, 41, 59, 0.58) 100%
) !important;
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(18px);
transition: border-color 0.22s ease, box-shadow 0.22s ease,
transform 0.22s ease;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
circle at 18% 0%,
rgba(34, 211, 238, 0.16),
transparent 34%
),
radial-gradient(
circle at 92% 16%,
rgba(99, 102, 241, 0.12),
transparent 36%
);
opacity: 0.72;
}
}
.ant-prompts-item-has-nest:hover {
border-color: rgba(34, 211, 238, 0.34) !important;
box-shadow: 0 28px 64px rgba(0, 0, 0, 0.3),
0 0 34px rgba(34, 211, 238, 0.1) !important;
}
.ant-prompts-label {
color: #e5eefb !important;
font-weight: 600 !important;
min-width: 0;
max-width: 100%;
}
.ant-prompts-desc {
color: #94a3b8 !important;
width: 100%;
min-width: 0;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
}
.ant-prompts-content {
position: relative;
z-index: 1;
min-width: 0 !important;
max-width: 100% !important;
overflow: hidden;
}
.ant-prompts-icon {
color: #67e8f9 !important;
flex: 0 0 auto;
}
.ant-prompts-item-has-nest > .ant-prompts-content {
display: flex !important;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
}
.ant-prompts-item-has-nest > .ant-prompts-content > .ant-prompts-label {
position: relative;
padding: 30px 32px 20px !important;
color: #e5eefb !important;
font-size: 22px !important;
font-weight: 700 !important;
line-height: 1.25 !important;
letter-spacing: 0.01em !important;
height: auto !important;
margin: 0 !important;
&::after {
content: '';
position: absolute;
left: 32px;
bottom: 10px;
width: 42px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, #22d3ee, transparent);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.42);
}
}
.ant-prompts-nested {
height: auto !important;
overflow: visible !important;
padding: 0 32px 30px !important;
}
.ant-prompts-nested .ant-prompts-list {
display: grid !important;
height: auto !important;
overflow: visible !important;
gap: 10px !important;
}
.ant-prompts-nested .ant-prompts-item {
position: relative;
width: 100% !important;
height: auto !important;
max-width: 100% !important;
min-width: 0 !important;
display: flex !important;
align-items: center !important;
gap: 14px !important;
min-height: 78px !important;
padding: 16px 46px 16px 16px !important;
border-radius: 18px !important;
background: linear-gradient(
135deg,
rgba(15, 23, 42, 0.76) 0%,
rgba(30, 41, 59, 0.52) 100%
) !important;
border: 1px solid rgba(125, 211, 252, 0.18) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 14px 30px rgba(0, 0, 0, 0.16) !important;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease, transform 0.2s ease;
&::after {
content: '';
position: absolute;
right: 16px;
top: 50%;
width: 8px;
height: 8px;
border-top: 2px solid currentColor;
border-right: 2px solid currentColor;
transform: translateY(-50%);
color: rgba(103, 232, 249, 0.38);
transition: color 0.2s ease, transform 0.2s ease;
}
&:hover {
transform: translateY(-2px);
background: linear-gradient(
135deg,
rgba(8, 47, 73, 0.74) 0%,
rgba(30, 41, 59, 0.68) 100%
) !important;
border-color: rgba(34, 211, 238, 0.42) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 18px 38px rgba(14, 165, 233, 0.14) !important;
}
&:hover::after {
color: rgba(103, 232, 249, 0.92);
transform: translate(3px, -50%);
}
.ant-prompts-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 14px;
background: rgba(34, 211, 238, 0.1);
border: 1px solid rgba(103, 232, 249, 0.2);
box-shadow: 0 0 18px rgba(34, 211, 238, 0.08);
}
.ant-prompts-label,
.ant-prompts-desc {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ant-prompts-label {
-webkit-line-clamp: 1;
color: #e2e8f0 !important;
font-size: 15px;
font-weight: 700 !important;
line-height: 1.5;
}
.ant-prompts-desc {
-webkit-line-clamp: 3;
color: #9fb3c8 !important;
font-size: 14px;
line-height: 1.65;
}
}
${responsive.xs} {
.ant-prompts-item-has-nest > .ant-prompts-content > .ant-prompts-label {
padding: 20px 20px 16px !important;
font-size: 18px !important;
&::after {
left: 20px;
}
}
.ant-prompts-nested {
padding: 0 20px 20px !important;
}
.ant-prompts-nested .ant-prompts-list {
gap: 8px !important;
}
.ant-prompts-nested .ant-prompts-item {
align-items: flex-start !important;
min-height: 72px !important;
padding: 14px 40px 14px 14px !important;
}
}
`,
chatList: css`
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 2px 4px 12px;
`,
chatListInner: css`
width: min(100%, var(--app-content-width));
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
padding-top: 0;
`,
bubbleList: css`
.ant-bubble-list {
gap: 22px;
}
.ant-bubble {
width: 100%;
max-width: 100%;
}
.ant-bubble-start,
.ant-bubble-end {
align-items: flex-start !important;
column-gap: 14px !important;
}
.ant-bubble-end {
justify-content: flex-start !important;
}
.ant-bubble-avatar {
width: 40px !important;
height: 40px !important;
margin-top: 3px !important;
flex: 0 0 40px !important;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(125, 211, 252, 0.22);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22),
0 0 18px rgba(34, 211, 238, 0.1);
overflow: hidden;
.ant-avatar,
img {
width: 40px !important;
height: 40px !important;
line-height: 40px !important;
border-radius: 15px !important;
font-size: 13px !important;
font-weight: 700;
}
}
.ant-bubble-content {
max-width: 100% !important;
}
.ant-bubble-content-wrapper {
min-width: 0;
max-width: min(760px, calc(100% - 54px));
}
.ant-bubble-end .ant-bubble-content-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.ant-bubble-footer {
width: 100%;
margin-top: 10px !important;
padding-top: 10px;
border-top: 1px solid rgba(125, 211, 252, 0.14);
}
.ant-bubble-end .ant-bubble-footer {
display: flex;
justify-content: flex-end;
}
${responsive.xs} {
.ant-bubble-list {
gap: 14px;
}
.ant-bubble-start,
.ant-bubble-end {
gap: 10px !important;
}
.ant-bubble-avatar {
width: 34px !important;
height: 34px !important;
margin-top: 4px !important;
flex-basis: 34px !important;
.ant-avatar,
img {
width: 34px !important;
height: 34px !important;
line-height: 34px !important;
font-size: 12px !important;
}
}
.ant-bubble-content-wrapper {
max-width: calc(100% - 42px);
}
}
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
color: #ffffff;
border: 1px solid rgba(186, 230, 253, 0.26) !important;
border-radius: 20px 20px 8px 20px !important;
box-shadow: 0 16px 36px rgba(14, 165, 233, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
padding: 13px 17px !important;
font-size: 15px;
line-height: 1.75;
overflow-wrap: anywhere;
`,
assistantBubble: css`
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.92) 0%,
rgba(15, 23, 42, 0.74) 100%
) !important;
border: 1px solid rgba(125, 211, 252, 0.18) !important;
border-radius: 22px 22px 22px 8px !important;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
color: #e5eefb !important;
padding: 16px 18px !important;
backdrop-filter: blur(18px);
overflow: hidden;
`,
toolBubble: css`
background: rgba(15, 23, 42, 0.68) !important;
border: 1px dashed rgba(125, 211, 252, 0.28) !important;
border-radius: 18px !important;
color: #cbd5e1 !important;
padding: 14px 16px !important;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #22d3ee 0%, #6366f1 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
min-height: 100%;
justify-content: flex-start;
padding-top: 26px;
padding-bottom: 2px;
${responsive.xs} {
padding-top: 12px;
}
`,
knowledgeContextCard: css`
margin-top: 16px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(125, 211, 252, 0.16);
color: #e5eefb;
display: grid;
gap: 8px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
strong {
color: #67e8f9;
font-size: 14px;
}
p {
margin: 0;
color: #94a3b8;
font-size: 13px;
line-height: 1.7;
}
`,
promptRow: css`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 22px;
width: 100%;
min-width: 0;
${responsive.md} {
grid-template-columns: 1fr;
}
`,
sender: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(125, 211, 252, 0.22) !important;
background: rgba(15, 23, 42, 0.86) !important;
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(18px);
padding: 12px !important;
}
.ant-sender-textarea {
background: transparent !important;
}
.ant-sender-input {
font-size: 15px;
color: #e5eefb !important;
}
.ant-sender-header {
margin-bottom: 8px;
border-radius: 18px;
overflow: hidden;
}
`,
speechButton: css`
font-size: 18px;
color: ${token.colorTextSecondary} !important;
`,
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 6px;
border-radius: 28px;
background: rgba(2, 6, 23, 0.32);
border: 1px solid rgba(125, 211, 252, 0.12);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.22);
`,
senderPrompt: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
color: ${token.colorText};
.ant-prompts-item {
border-radius: 16px !important;
background: rgba(15, 23, 42, 0.72) !important;
border: 1px solid rgba(125, 211, 252, 0.16) !important;
}
`,
thinkingCard: css`
border-radius: 18px;
background: rgba(2, 6, 23, 0.34);
border: 1px solid rgba(125, 211, 252, 0.16);
overflow: hidden;
.ant-collapse-header {
padding: 10px 14px !important;
color: #a5b4fc !important;
font-weight: 600;
}
.ant-collapse-content-box {
padding: 0 14px 14px !important;
}
`,
markdown: css`
font-size: 15px;
line-height: 1.8;
color: #e5eefb;
overflow-wrap: anywhere;
> * {
max-width: 100%;
}
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
pre {
overflow-x: auto;
border-radius: 16px;
background: rgba(2, 6, 23, 0.86);
color: #e2e8f0;
padding: 14px 16px;
}
hr {
margin: 16px 0;
border: 0;
border-top: 1px solid rgba(125, 211, 252, 0.16);
}
blockquote {
margin: 12px 0;
padding: 2px 0 2px 14px;
border-left: 3px solid rgba(34, 211, 238, 0.38);
color: #cbd5e1;
}
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo,
monospace;
}
`,
assistantActions: css`
display: inline-flex;
align-items: center;
gap: 4px !important;
.ant-btn {
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 10px;
color: #64748b;
}
.ant-btn:hover {
background: rgba(34, 211, 238, 0.1) !important;
color: #67e8f9 !important;
}
`,
};
});
export default forwardRef((props, ref) => {
const { userInfo, conversation, onAddConversation } = props;
const { styles } = useStyle();
const { token } = theme.useToken();
const conversationAgent = useMemo(
() => getConversationAgent(conversation?.metadata),
[conversation],
......@@ -907,7 +259,6 @@ export default forwardRef((props, ref) => {
},
});
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [auditKnowledgeOpen, setAuditKnowledgeOpen] = useState(false);
......@@ -915,7 +266,6 @@ export default forwardRef((props, ref) => {
const [auditKnowledgeLoading, setAuditKnowledgeLoading] = useState(false);
const [auditRecords, setAuditRecords] = useState([]);
const [auditKnowledgeFile, setAuditKnowledgeFile] = useState(null);
const senderRef = useRef(null);
const abortController = useRef(null);
const auditRecordsRef = useRef([]);
const auditSessionKeyRef = useRef('');
......@@ -972,7 +322,7 @@ export default forwardRef((props, ref) => {
return await loadPromise;
} catch (error) {
if (auditSessionKeyRef.current === sessionKey) {
message.error(
toast.danger(
`审计知识库 Excel 读取失败:${error.message || '未知错误'}`,
);
}
......@@ -1391,7 +741,7 @@ export default forwardRef((props, ref) => {
return `${auditKnowledgePrompt}${searchPrompt}${filePrompt}${kbPrompt}用户提问:${message.content}`;
};
const [agent] = useXAgent({
const [agent] = useChatAgent({
request: async (
{ message, messages },
{ onUpdate, onSuccess, onError, onStream },
......@@ -1475,9 +825,7 @@ export default forwardRef((props, ref) => {
});
const response = await llmChat(data, controller.signal);
const stream = XStream({
readableStream: response.body,
});
const stream = createSseStream(response.body);
for await (const chunk of stream) {
onUpdate(chunk);
......@@ -1607,9 +955,7 @@ export default forwardRef((props, ref) => {
};
const toolResponse = await llmChat(toolData, controller.signal);
const toolStream = XStream({
readableStream: toolResponse.body,
});
const toolStream = createSseStream(toolResponse.body);
for await (const chunk of toolStream) {
onUpdate(chunk);
......@@ -1633,7 +979,7 @@ export default forwardRef((props, ref) => {
});
const loading = agent.isRequesting();
const { onRequest, messages, setMessages } = useXChat({
const { onRequest, messages, setMessages } = useChat({
agent,
requestFallback: (message, { error }) => {
console.log('error', error);
......@@ -1657,237 +1003,6 @@ export default forwardRef((props, ref) => {
},
});
const messageItems = useMemo(() => {
return messages?.map((item, index) => {
const messageItem = {
...item.message,
classNames: {
content: item.status === 'loading' ? styles.loadingMessage : '',
},
loading: item.message?.loading,
};
if (item.message.role === 'user') {
messageItem.classNames = {
content: styles.userBubble,
};
messageItem.messageRender = (content) => {
return (
<Flex vertical gap={8}>
<div>{content}</div>
{item.message.files?.map((file, index) => (
<Attachments.FileCard key={index} item={file} />
))}
</Flex>
);
};
}
if (item.message.role === 'assistant') {
let previousQuestion = '';
for (
let messageIndex = index - 1;
messageIndex >= 0;
messageIndex -= 1
) {
if (messages[messageIndex]?.message?.role === 'user') {
previousQuestion = messages[messageIndex].message.content || '';
break;
}
}
const allowAuditWordExport =
conversationAgent?.key === 'audit-checklist';
const auditExport = allowAuditWordExport
? item.message.auditExport ||
(previousQuestion
? {
question: previousQuestion,
title: '审计检查清单',
}
: null)
: null;
messageItem.classNames = {
content: `${item.status === 'loading' ? styles.loadingMessage : ''} ${
styles.assistantBubble
}`,
};
messageItem.variant = 'borderless';
messageItem.footer = (
<Space className={styles.assistantActions}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(item.message.content);
success('已复制到剪贴板');
}}
/>
{index === messages.length - 1 && (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => {
const userMessageIndex = messages.findLastIndex(
(m) => m.message.role === 'user',
);
if (userMessageIndex >= 0) {
setMessages((messages) =>
messages.slice(0, userMessageIndex),
);
onSubmit(messages[userMessageIndex]?.message);
}
}}
/>
)}
</Space>
);
messageItem.messageRender = () => {
return (
<Flex vertical gap={8} style={{ width: '100%' }}>
{item.message.think && (
<Collapse
className={styles.thinkingCard}
size="small"
bordered={false}
style={{
alignSelf: 'flex-start',
}}
defaultActiveKey="think"
items={[
{
key: 'think',
showArrow: false,
label:
item.status === 'success' || item.message.content ? (
<Space>
<BulbOutlined />
思考完成
</Space>
) : (
<Space>
<LoadingOutlined />
正在思考...
</Space>
),
children: (
<Typography.Paragraph style={{ marginBottom: 0 }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(
preprocessLaTeX(item.message.think),
),
}}
></div>
</Typography.Paragraph>
),
},
]}
/>
)}
{item.message.tools?.length > 0 && (
<Collapse
className={styles.thinkingCard}
size="small"
bordered={false}
style={{
alignSelf: 'flex-start',
}}
defaultActiveKey="think"
items={item.message.tools.map((tool) => ({
key: 'tool_' + tool.function.name,
showArrow: false,
label: (
<Space>
<ToolOutlined />
工具调用:{tool.function.name}
</Space>
),
children: (
<Typography.Paragraph style={{ marginBottom: 0 }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(
preprocessLaTeX(
'```json\n' + tool.function.arguments + '\n```',
),
),
}}
></div>
</Typography.Paragraph>
),
}))}
/>
)}
{item.message.auditResult?.kind !== 'checklist' && (
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(preprocessLaTeX(item.message.content)),
}}
></div>
)}
{item.message.auditResult && (
<AuditChecklistResult
result={item.message.auditResult}
aiContent={item.message.content}
allowWordExport={allowAuditWordExport}
/>
)}
{item.status === 'success' && auditExport && (
<AuditWordDownload
records={auditRecords}
aiContent={item.message.content}
question={auditExport.question}
title={auditExport.title}
/>
)}
</Flex>
);
};
}
if (item.message.role === 'tool') {
messageItem.classNames = {
content: styles.toolBubble,
};
messageItem.messageRender = () => {
return (
<>
{item.message.tool && (
<Collapse
size="small"
bordered={false}
items={[
{
key: 'think',
label: (
<Space>
<BulbOutlined />
工具调用:{item.message.tool}
</Space>
),
children: item.message.think,
showArrow: false,
},
]}
/>
)}
<div>{item.message.content}</div>
</>
);
};
}
return messageItem;
});
}, [auditRecords, conversationAgent, conversationTopic, messages]);
useEffect(() => {
abortController?.current?.abort('cancelled');
const messages = conversation?.metadata?.messages || [];
......@@ -1897,8 +1012,6 @@ export default forwardRef((props, ref) => {
message: message,
})),
);
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
......@@ -1915,12 +1028,33 @@ export default forwardRef((props, ref) => {
if (userInfo) {
getApplicationRuntimeConfig().catch((error) => {
console.error(error);
message.error(error.message || '应用模型配置读取失败');
toast.danger(error.message || '应用模型配置读取失败');
});
loadAuditRecords().catch(() => {});
}
}, [loadAuditRecords, userInfo]);
const navbarActions = useMemo(
() =>
!conversation ? (
<AuditKnowledgeActions
fileName={auditKnowledgeFile?.file?.name}
loading={auditKnowledgeLoading}
onOpen={() => setAuditKnowledgeOpen(true)}
onOpenPrompt={() => setAuditPromptOpen(true)}
onRefresh={() => loadAuditRecords(true)}
/>
) : null,
[
auditKnowledgeFile?.file?.name,
auditKnowledgeLoading,
conversation,
loadAuditRecords,
],
);
useNavbarActions(navbarActions);
useEffect(() => {
if (conversation) {
const oldMessage = _.last(conversation.metadata.messages);
......@@ -2013,7 +1147,7 @@ export default forwardRef((props, ref) => {
}
if (loading) {
message.warning('请等待请求完成');
toast.warning('请等待请求完成');
return;
}
......@@ -2035,242 +1169,141 @@ export default forwardRef((props, ref) => {
/>
);
}
const chatList = (
<div className={styles.chatList}>
{conversation ? (
<div className={styles.chatListInner}>
{(conversationTopic || conversationAgent) && (
<Welcome
title={
conversationAgent?.label ||
conversationTopic?.title ||
'智能助手'
}
description={
conversationTopic?.title
? `当前话题:${conversationTopic.title}`
: conversationAgent?.description
const handleFilesSelected = async (files) => {
const pendingFiles = files.map((file) => ({
file,
name: file.name,
size: file.size,
status: 'uploading',
type: file.type,
uid: `${file.name}-${file.lastModified}-${Math.random()}`,
}));
setAttachedFiles((current) => [...current, ...pendingFiles]);
await Promise.all(
pendingFiles.map(async (pendingFile) => {
try {
const response = await uploadAuditKnowledgeFile(pendingFile.file);
setAttachedFiles((current) =>
current.map((item) =>
item.uid === pendingFile.uid
? { ...item, response, status: 'done' }
: item,
),
);
} catch (error) {
setAttachedFiles((current) =>
current.filter((item) => item.uid !== pendingFile.uid),
);
toast.danger(`${pendingFile.name} 上传失败:${error.message}`);
}
style={{
background:
'linear-gradient(135deg, rgba(15, 23, 42, 0.86) 0%, rgba(30, 41, 59, 0.68) 100%)',
border: '1px solid rgba(125, 211, 252, 0.22)',
borderRadius: 22,
color: '#e5eefb',
boxShadow:
'0 18px 46px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.08)',
}}
/>
)}
{conversation.metadata.knowledgeBases?.length > 0 && (
<div className={styles.knowledgeContextCard}>
<strong>已连接知识库</strong>
<p>
{conversation.metadata.knowledgeBases
.map((kb) => kb.title || kb.name)
.join(' / ')}
</p>
</div>
)}
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
style={{
height: '100%',
}}
roles={{
assistant: {
placement: 'start',
avatar: {
children: 'AI',
style: {
background:
'linear-gradient(135deg, rgba(34,211,238,0.18), rgba(99,102,241,0.22))',
color: '#67e8f9',
},
},
},
user: {
placement: 'end',
avatar: userInfo?.avatar
? { src: userInfo.avatar }
: {
children: userInfo?.display_name?.[0] || '我',
style: {
background:
'linear-gradient(135deg, rgba(37,99,235,0.95), rgba(14,165,233,0.92))',
color: '#ffffff',
},
},
},
}}
/>
</div>
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<AuditKnowledgeBar
count={auditRecords.length}
fileName={auditKnowledgeFile?.file?.name}
loading={auditKnowledgeLoading}
onOpen={() => setAuditKnowledgeOpen(true)}
onOpenPrompt={() => setAuditPromptOpen(true)}
onRefresh={() => loadAuditRecords(true)}
/>
<div className={styles.promptRow}>
{promptGroups.map((group) => (
<Prompts
key={group.key}
items={[group]}
style={{
flex: 1,
}}
styles={{
list: { height: '100%' },
subItem: { padding: 0 },
}}
onItemClick={async (info) => {
const selectedAgent = info.data.prompt
? info.data
: info.data.agent;
}),
);
};
onSubmit({
content: info.data.content || info.data.initialContent,
topic: info.data.topic,
agent: selectedAgent,
knowledgeBases: info.data.knowledgeBases,
});
}}
className={styles.chatPrompt}
const regenerateLastResponse = () => {
const userMessageIndex = messages.findLastIndex(
(item) => item.message?.role === 'user',
);
if (userMessageIndex < 0) return;
const userMessage = messages[userMessageIndex].message;
setMessages((current) => current.slice(0, userMessageIndex));
onSubmit(userMessage);
};
const renderAssistantExtras = (item) => {
if (item.message?.role !== 'assistant') return null;
const allowWordExport = conversationAgent?.key === 'audit-checklist';
const auditExport = allowWordExport ? item.message.auditExport : null;
return (
<>
{item.message.auditResult ? (
<AuditChecklistResult
aiContent={item.message.content}
allowWordExport={allowWordExport}
result={item.message.auditResult}
/>
) : null}
{item.status === 'success' && auditExport ? (
<AuditWordDownload
aiContent={item.message.content}
question={auditExport.question}
records={auditRecords}
title={auditExport.title}
/>
))}
</div>
</div>
)}
</div>
) : null}
</>
);
};
const senderHeader = (
<Sender.Header
title="上传文件"
open={attachmentsOpen}
onOpenChange={setAttachmentsOpen}
styles={{ content: { padding: 0 } }}
>
<Attachments
items={attachedFiles}
action="/api/upload"
onChange={(info) => {
setAttachedFiles(info.fileList);
}}
placeholder={(type) =>
type === 'drop'
? {
title: '拖动文件到这里',
return (
<>
<ChatWorkspace
canConfigure={
Boolean(conversation) &&
(conversationAgent?.key === 'audit-checklist' ||
conversationTopic?.name === AUDIT_TOPIC.name)
}
: {
icon: <CloudUploadOutlined />,
title: '点击或拖动文件到这里',
description:
'支持文件类型: DOC, DOCX, XSL, XSLX, PDF, JPG, PNG',
description={
conversationTopic?.title
? `当前话题:${conversationTopic.title}`
: conversationAgent?.description
}
files={attachedFiles}
inputValue={inputValue}
isLoading={loading}
knowledgeBases={conversation?.metadata?.knowledgeBases || []}
messages={messages}
questions={conversationTopic?.metadata?.questions || []}
renderAssistantExtras={renderAssistantExtras}
suggestionGroups={!conversation ? promptGroups : []}
title={
conversation
? conversationAgent?.label || conversationTopic?.title || '智能助手'
: undefined
}
/>
</Sender.Header>
);
const chatSender = (
<div className={styles.senderShell}>
{messages.length === 0 && conversationTopic && (
<Prompts
vertical
className={styles.senderPrompt}
styles={{
item: {
padding: '6px 12px',
},
}}
items={conversationTopic.metadata.questions?.map((q) => ({
key: q,
description: q,
}))}
onItemClick={(info) => {
onSubmit({
content: info.data.description,
});
}}
/>
)}
<Sender
ref={senderRef}
value={inputValue}
header={senderHeader}
userInfo={userInfo}
onConfigure={() => setAuditPromptOpen(true)}
onFilesSelected={handleFilesSelected}
onInputChange={setInputValue}
onQuestion={(question) => onSubmit({ content: question })}
onRegenerate={regenerateLastResponse}
onRemoveFile={(file) =>
setAttachedFiles((current) =>
current.filter((item) => item.uid !== file.uid),
)
}
onStop={() => abortController.current?.abort()}
onSubmit={() => {
onSubmit({
content: inputValue,
});
const content = inputValue.trim();
if (!content) return;
onSubmit({ content });
setInputValue('');
}}
onChange={setInputValue}
onCancel={() => abortController.current?.abort()}
prefix={
<Space size={2}>
{(conversationAgent?.key === 'audit-checklist' ||
conversationTopic?.name === AUDIT_TOPIC.name) && (
<Tooltip title="配置审计提示词">
<Button
type="text"
aria-label="配置审计提示词"
icon={<SettingOutlined style={{ fontSize: 18 }} />}
onClick={() => setAuditPromptOpen(true)}
/>
</Tooltip>
)}
<Button
type="text"
aria-label="上传附件"
icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
onClick={() => setAttachmentsOpen(!attachmentsOpen)}
/>
</Space>
}
loading={loading}
className={styles.sender}
allowSpeech
actions={(_, info) => {
const { SendButton, LoadingButton, SpeechButton } = info.components;
return (
<Flex gap={4}>
<SpeechButton className={styles.speechButton} />
{loading ? (
<LoadingButton type="default" />
) : (
<SendButton type="primary" />
)}
</Flex>
);
onSuggestion={(item) => {
const selectedAgent = item.prompt ? item : item.agent;
onSubmit({
agent: selectedAgent,
content: item.content || item.initialContent,
knowledgeBases: item.knowledgeBases,
topic: item.topic,
});
}}
placeholder="请输入消息内容"
/>
</div>
);
return (
<div className={styles.chat}>
{chatList}
{chatSender}
<AuditKnowledgeImportDialog
open={auditKnowledgeOpen}
onSave={saveAuditKnowledgeFile}
onCancel={() => setAuditKnowledgeOpen(false)}
onImported={() => loadAuditRecords(true)}
onSave={saveAuditKnowledgeFile}
/>
<AuditPromptConfigDialog
open={auditPromptOpen}
value={configuredAuditTopic?.metadata}
onSave={saveAuditPromptConfig}
onCancel={() => setAuditPromptOpen(false)}
onSave={saveAuditPromptConfig}
/>
</div>
</>
);
});
import {
BulbOutlined,
CheckCircleOutlined,
CloudUploadOutlined,
CopyOutlined,
DownloadOutlined,
PaperClipOutlined,
PayCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import {
Attachments,
Bubble,
Prompts,
Sender,
XStream,
useXAgent,
useXChat,
} from '@ant-design/x';
import { App, Button, Flex, Space, Spin, Steps, Tag, Typography } from 'antd';
import { createStyles } from 'antd-style';
import ChatWorkspace from '@/components/ChatWorkspace';
import { createSseStream, useChat, useChatAgent } from '@/utils/chatRuntime';
import BulbOutlined from '@ant-design/icons/BulbOutlined';
import { Stepper } from '@heroui-pro/react/stepper';
import { Button, Surface, toast } from '@heroui/react';
import 'katex/dist/katex.min.css';
import _ from 'lodash';
import { marked } from 'marked';
......@@ -75,364 +59,30 @@ const SYSTEM_PROMPT = `你叫Mia,是一个有用的的发票智能助手。如
如果你不知道答案,请回答你只能根据上下文的内容回答问题。在识别完文件之后,需要调用mcp工具生成用户可能需要的问题选项。用户确认数量没问题之后,需要生成一个表单供用户填写。接收到的是html的时候,assistent角色回复的消息需要是html格式,只要用户发送了表单信息,那么无论表单信息内容是否为空,都需要调用mcp工具对表单信息进行标准化处理
`;
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
gap: 18px;
overflow: hidden;
${responsive.md} {
flex-direction: column;
}
`,
chatWithSidebar: css`
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1;
order: 1;
`,
stepsSidebar: css`
width: 300px;
min-width: 300px;
height: 100%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 28px;
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
order: 2;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
${responsive.md} {
width: 100%;
min-width: 0;
max-height: 280px;
}
`,
sidebarTitle: css`
font-size: 18px;
font-weight: 600;
color: #e5eefb;
display: flex;
align-items: center;
gap: 8px;
`,
chatList: css`
flex: 1;
min-height: 0;
overflow: auto;
padding: 2px 2px 10px;
`,
chatListInner: css`
display: flex;
flex-direction: column;
min-height: 100%;
width: min(100%, var(--app-content-width));
margin: 0 auto;
gap: 16px;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #3f9c35 0%, #009cde 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
justify-content: center;
padding-top: 10px;
`,
sender: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(148, 163, 184, 0.18) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08) !important;
padding: 10px !important;
}
.ant-sender-header {
margin-bottom: 8px;
border-radius: 18px;
overflow: hidden;
}
`,
speechButton: css`
font-size: 18px;
color: ${token.colorTextSecondary} !important;
`,
senderPrompt: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
color: ${token.colorText};
`,
assistantBubble: css`
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.92) 0%,
rgba(15, 23, 42, 0.74) 100%
) !important;
border: 1px solid rgba(125, 211, 252, 0.18) !important;
border-radius: 22px 22px 22px 8px !important;
padding: 16px 18px !important;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
color: #e5eefb !important;
backdrop-filter: blur(18px);
overflow: hidden;
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
color: #ffffff;
border: 1px solid rgba(186, 230, 253, 0.26) !important;
border-radius: 20px 20px 8px 20px !important;
box-shadow: 0 16px 36px rgba(14, 165, 233, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
padding: 13px 17px !important;
font-size: 15px;
line-height: 1.75;
overflow-wrap: anywhere;
`,
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 8px;
border-radius: 30px;
background: rgba(2, 6, 23, 0.32);
border: 1px solid rgba(125, 211, 252, 0.12);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.22);
`,
bubbleList: css`
.ant-bubble-list {
gap: 22px;
}
.ant-bubble {
width: 100%;
max-width: 100%;
}
.ant-bubble-start,
.ant-bubble-end {
align-items: flex-start !important;
column-gap: 14px !important;
}
.ant-bubble-end {
justify-content: flex-start !important;
}
.ant-bubble-avatar {
width: 40px !important;
height: 40px !important;
margin-top: 3px !important;
flex: 0 0 40px !important;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(125, 211, 252, 0.22);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22),
0 0 18px rgba(34, 211, 238, 0.1);
overflow: hidden;
.ant-avatar,
img {
width: 40px !important;
height: 40px !important;
line-height: 40px !important;
border-radius: 15px !important;
font-size: 13px !important;
font-weight: 700;
}
}
.ant-bubble-content {
max-width: 100% !important;
}
.ant-bubble-content-wrapper {
min-width: 0;
max-width: min(760px, calc(100% - 54px));
}
.ant-bubble-end .ant-bubble-content-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.ant-bubble-footer {
width: 100%;
margin-top: 8px !important;
padding-top: 8px;
border-top: 1px solid rgba(125, 211, 252, 0.14);
}
.ant-bubble-end .ant-bubble-footer {
display: flex;
justify-content: flex-end;
}
${responsive.xs} {
.ant-bubble-list {
gap: 14px;
}
.ant-bubble-start,
.ant-bubble-end {
gap: 10px !important;
}
.ant-bubble-avatar {
width: 32px !important;
height: 32px !important;
margin-top: 5px !important;
flex-basis: 32px !important;
.ant-avatar,
img {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
font-size: 12px !important;
}
}
.ant-bubble-content-wrapper {
max-width: calc(100% - 42px);
}
}
`,
markdown: css`
font-size: 15px;
line-height: 1.8;
color: #e5eefb;
overflow-wrap: anywhere;
> * {
max-width: 100%;
}
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
pre {
overflow-x: auto;
border-radius: 16px;
background: rgba(2, 6, 23, 0.86);
color: #e2e8f0;
padding: 14px 16px;
}
hr {
margin: 16px 0;
border: 0;
border-top: 1px solid rgba(125, 211, 252, 0.16);
}
blockquote {
margin: 12px 0;
padding: 2px 0 2px 14px;
border-left: 3px solid rgba(14, 165, 233, 0.28);
color: #cbd5e1;
}
`,
assistantActions: css`
display: inline-flex;
align-items: center;
gap: 4px !important;
.ant-btn {
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 10px;
color: #64748b;
}
.ant-btn:hover {
background: rgba(14, 165, 233, 0.1) !important;
color: #0284c7 !important;
}
`,
heroCard: css`
padding: 28px;
border-radius: 30px;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.32),
transparent 24%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.08);
`,
promptCard: css`
.ant-prompts-item,
.ant-prompts-sub-item {
border-radius: 18px !important;
border: 1px solid rgba(148, 163, 184, 0.14) !important;
background: rgba(255, 255, 255, 0.8) !important;
}
`,
stepHint: css`
color: #64748b;
font-size: 13px;
line-height: 1.6;
`,
downloadButton: css`
margin-top: auto;
width: 100%;
height: 42px;
`,
sectionTag: css`
width: fit-content;
`,
};
});
const InvoiceAssistant = forwardRef((props, ref) => {
const { conversation, onAddConversation, userInfo } = props;
const { styles } = useStyle();
const { message: messageApi } = App.useApp();
const messageApi = useMemo(
() => ({
error: toast.danger,
info: toast.info,
loading: (content) => {
toast.info(content);
return () => {};
},
success: toast.success,
warning: toast.warning,
}),
[],
);
// 发票助手相关状态
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [currentStep, setCurrentStep] = useState(0);
const projectInfo = useRef({});
const senderRef = useRef(null);
const abortController = useRef(null);
const outputListRef = useRef([]);
const renamedFilesRef = useRef([]);
const processedFilesRef = useRef([]); // 存储处理后的文件列表(包含解压后的文件,不包含ZIP文件)
const chatEndRef = useRef(null);
const chatContainerRef = useRef(null);
// 由智能体思考并填充的步骤列表(进入页面后由助手输出决定)
......@@ -826,7 +476,7 @@ const InvoiceAssistant = forwardRef((props, ref) => {
return `用户回答:${message.content}`;
};
const [agent] = useXAgent({
const [agent] = useChatAgent({
request: async (
{ message, messages },
{ onUpdate, onSuccess, onError, onStream },
......@@ -844,7 +494,6 @@ const InvoiceAssistant = forwardRef((props, ref) => {
const tools = await getTools();
let requestMessages = [];
if (message.files?.length > 0) {
setAttachmentsOpen(false);
requestMessages = [
{
...message,
......@@ -893,9 +542,7 @@ const InvoiceAssistant = forwardRef((props, ref) => {
}
const response = await llmChat(data, controller.signal);
const stream = XStream({
readableStream: response.body,
});
const stream = createSseStream(response.body);
for await (const chunk of stream) {
onUpdate(chunk);
chunks.push(chunk);
......@@ -1203,9 +850,7 @@ const InvoiceAssistant = forwardRef((props, ref) => {
],
};
const toolResponse = await llmChat(toolData, controller.signal);
const toolStream = XStream({
readableStream: toolResponse.body,
});
const toolStream = createSseStream(toolResponse.body);
for await (const chunk of toolStream) {
onUpdate(chunk);
......@@ -1361,7 +1006,7 @@ const InvoiceAssistant = forwardRef((props, ref) => {
});
const agentLoading = agent.isRequesting();
const { onRequest, messages, setMessages } = useXChat({
const { onRequest, messages, setMessages } = useChat({
agent,
requestFallback: (message, { error }) => {
if (error.name === 'AbortError') {
......@@ -1383,131 +1028,6 @@ const InvoiceAssistant = forwardRef((props, ref) => {
},
});
const messageItems = useMemo(() => {
// 过滤掉包含"打招呼"的用户消息(自动发送的欢迎消息)
const visibleMessages = messages?.filter((item) => {
if (!item?.message) {
return false;
}
if (
item.message.role === 'user' &&
item.message.content?.includes('打招呼')
) {
return false;
}
if (item.message.metadata?.hidden) {
return false;
}
return true;
});
return visibleMessages?.map((item, index) => {
const messageItem = {
...item.message,
classNames: {
content: '',
},
loading: item.message?.loading,
};
const contentClasses = [];
if (item.status === 'loading') {
contentClasses.push(styles.loadingMessage);
}
if (item.message.role === 'user') {
messageItem.classNames = {
content: styles.userBubble,
};
messageItem.messageRender = (content) => {
return (
<Flex vertical gap={8}>
<div>{content}</div>
{item.message.files?.map((file, index) => (
<Attachments.FileCard key={index} item={file} />
))}
</Flex>
);
};
}
if (item.message.role === 'assistant') {
const isLastAssistant =
item.message.role === 'assistant' &&
index === visibleMessages.length - 1;
contentClasses.push(styles.assistantBubble);
messageItem.variant = 'borderless';
messageItem.footer = (
<Space className={styles.assistantActions}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(item.message.content);
messageApi.success('已复制到剪贴板');
}}
/>
{index === messages.length - 1 && (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => {
const userMessageIndex = messages.findLastIndex(
(m) => m.message.role === 'user',
);
if (userMessageIndex >= 0) {
setMessages((messages) =>
messages.slice(0, userMessageIndex),
);
onSubmit(messages[userMessageIndex]?.message);
}
}}
/>
)}
</Space>
);
messageItem.messageRender = () => {
const content = item.message.content?.trim();
if (!content) {
return (
<Space>
<Spin size="small" />
正在生成回复,请稍候...
</Space>
);
}
return (
<Flex vertical gap={8} style={{ width: '100%' }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(preprocessLaTeX(content)),
}}
></div>
</Flex>
);
};
}
if (contentClasses.length > 0) {
messageItem.classNames = {
...messageItem.classNames,
content: contentClasses.join(' '),
};
}
return messageItem;
});
}, [messages]);
useEffect(() => {
const container = chatContainerRef.current;
if (!container) {
......@@ -1624,21 +1144,6 @@ const InvoiceAssistant = forwardRef((props, ref) => {
};
}, [onSubmit]);
useEffect(() => {
if (!conversation) {
return;
}
const frame = requestAnimationFrame(() => {
chatEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
});
return () => cancelAnimationFrame(frame);
}, [conversation, messages]);
useEffect(() => {
abortController?.current?.abort('cancelled');
const messages = conversation?.metadata?.messages || [];
......@@ -1669,8 +1174,6 @@ const InvoiceAssistant = forwardRef((props, ref) => {
extractStepsFromMessages(messages[1]);
}
}
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
......@@ -1750,230 +1253,166 @@ const InvoiceAssistant = forwardRef((props, ref) => {
});
};
const chatList = (
<div className={styles.chatList} ref={chatContainerRef}>
{conversation ? (
<div className={styles.chatListInner}>
<div className={styles.heroCard}>
<Space direction="vertical" size={16}>
<Tag color="cyan" bordered={false} className={styles.sectionTag}>
发票识别工作流
</Tag>
<Typography.Title level={3} style={{ margin: 0 }}>
Mia 发票助手
</Typography.Title>
<Typography.Paragraph
style={{ margin: 0, color: '#64748b', lineHeight: 1.7 }}
>
支持 ZIP、PDF
和图片批量识别,自动联想下一步问题,并在流程完成后生成可下载结果。
</Typography.Paragraph>
</Space>
</div>
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
style={{
height: '100%',
}}
roles={{
assistant: {
placement: 'start',
avatar: {
children: 'Mia',
style: { background: '#fde3cf', color: '#9a3412' },
},
},
user: {
placement: 'end',
avatar: userInfo?.avatar
? { src: userInfo.avatar }
: { children: userInfo?.display_name?.[0] || '我' },
},
}}
/>
</div>
{thinkPromptItems.length > 0 && (
<Prompts
items={thinkPromptItems}
style={{ width: '100%' }}
className={styles.promptCard}
onItemClick={(info) => {
const promptContent =
info?.data?.content ||
info?.data?.description ||
info?.data?.title;
if (promptContent) {
onSubmit({
content: promptContent,
const handleFilesSelected = async (files) => {
const pendingFiles = files.map((file) => ({
file,
name: file.name,
size: file.size,
status: 'uploading',
type: file.type,
uid: `${file.name}-${file.lastModified}-${Math.random()}`,
}));
setAttachedFiles((current) => [...current, ...pendingFiles]);
await Promise.all(
pendingFiles.map(async (pendingFile) => {
const formData = new FormData();
formData.append('file', pendingFile.file);
try {
const response = await fetch('/api/upload', {
body: formData,
credentials: 'include',
method: 'POST',
});
}
}}
/>
)}
<div ref={chatEndRef} />
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<div className={styles.heroCard}>
<Typography.Title level={3} style={{ margin: 0 }}>
发票助手已就绪
</Typography.Title>
<Typography.Paragraph
style={{ margin: '10px 0 0', color: '#64748b', lineHeight: 1.7 }}
>
上传文件后,Mia 会按步骤帮你完成识别、确认、补充表单和结果下载。
</Typography.Paragraph>
</div>
</div>
)}
</div>
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const uploaded = payload?.file || payload?.data || payload;
const normalized = {
...uploaded,
name: uploaded?.name || pendingFile.name,
size: uploaded?.size || pendingFile.size,
type: uploaded?.type || pendingFile.type,
url: uploaded?.url || uploaded?.path,
};
setAttachedFiles((current) =>
current.map((item) =>
item.uid === pendingFile.uid
? { ...item, response: normalized, status: 'done' }
: item,
),
);
const senderHeader = (
<Sender.Header
title="上传文件"
open={attachmentsOpen}
onOpenChange={setAttachmentsOpen}
styles={{ content: { padding: 0 } }}
>
<Attachments
items={attachedFiles}
action="/api/upload"
onChange={(info) => {
setAttachedFiles(info.fileList);
}}
multiple={true}
placeholder={(type) =>
type === 'drop'
? {
title: '拖动文件到这里',
}
: {
icon: <CloudUploadOutlined />,
title: '点击或拖动文件到这里',
description: '支持文件类型: ZIP, PDF, JPG, PNG',
}
}
/>
</Sender.Header>
} catch (error) {
setAttachedFiles((current) =>
current.filter((item) => item.uid !== pendingFile.uid),
);
const chatSender = (
<div className={styles.senderShell}>
<Sender
ref={senderRef}
value={inputValue}
header={senderHeader}
onSubmit={() => {
onSubmit({
content: inputValue,
});
setInputValue('');
}}
onChange={setInputValue}
onCancel={() => abortController.current?.abort()}
prefix={
<Button
type="text"
icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
onClick={() => setAttachmentsOpen(!attachmentsOpen)}
/>
toast.danger(`${pendingFile.name} 上传失败:${error.message}`);
}
loading={agentLoading}
className={styles.sender}
allowSpeech
submitType="enter"
actions={(_, info) => {
const { SendButton, LoadingButton, SpeechButton } = info.components;
return (
<Flex gap={4}>
<SpeechButton className={styles.speechButton} />
{agentLoading ? (
<LoadingButton type="default" />
) : (
<SendButton type="primary" />
)}
</Flex>
);
}}
placeholder="请输入消息内容"
/>
</div>
}),
);
};
// 右侧步骤栏组件
const stepsSidebar = (
<div className={styles.stepsSidebar}>
<div className={styles.sidebarTitle}>
<PayCircleOutlined />
发票助手流程
</div>
<div className={styles.stepHint}>
右侧面板会同步展示当前进度,方便在批量处理发票时快速确认下一步。
</div>
const regenerateLastResponse = () => {
const userMessageIndex = messages.findLastIndex(
(item) => item.message?.role === 'user',
);
if (userMessageIndex < 0) return;
const userMessage = messages[userMessageIndex].message;
setMessages((current) => current.slice(0, userMessageIndex));
onSubmit(userMessage);
};
{stepsList.length > 0 && (
<Steps
direction="vertical"
current={currentStep}
items={stepsList.map((step, index) => ({
title: step.title,
description: step.description,
status:
index < currentStep
? 'finish'
: index === currentStep
? 'process'
: 'wait',
icon:
index < currentStep ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : undefined,
}))}
style={{
'--ant-steps-item-finish-icon-color': '#52c41a',
'--ant-steps-item-finish-line-color': '#52c41a',
}}
/>
const suggestionGroups = thinkPrompts.length
? [
{
children: thinkPrompts.map((item, index) => ({
...item,
description: item.description,
key: item.key || `${item.title}-${index}`,
label: item.title,
})),
key: 'invoice-next-actions',
label: '建议下一步',
},
]
: [];
const stepsPanel = (
<Surface className="rc-invoice-steps" variant="default">
<h2>发票助手流程</h2>
<p>当前进度与下一步操作</p>
{stepsList.length ? (
<Stepper currentStep={currentStep} orientation="vertical">
{stepsList.map((step) => (
<Stepper.Step key={step.title}>
<Stepper.Indicator />
<Stepper.Content>
<Stepper.Title>{step.title}</Stepper.Title>
<Stepper.Description>{step.description}</Stepper.Description>
</Stepper.Content>
<Stepper.Separator />
</Stepper.Step>
))}
</Stepper>
) : (
<p>会话开始后显示处理步骤</p>
)}
{stepsList.length > 0 && currentStep === stepsList.length - 1 && (
{stepsList.length > 0 && currentStep === stepsList.length - 1 ? (
<Button
type="primary"
icon={<DownloadOutlined />}
size="small"
onClick={async () => {
const excelData = {
outputList: outputListRef.current,
info: projectInfo.current,
files: renamedFilesRef.current,
};
variant="primary"
onPress={async () => {
try {
await generateExcelZip(excelData);
await generateExcelZip({
files: renamedFilesRef.current,
info: projectInfo.current,
outputList: outputListRef.current,
});
} catch (error) {
console.error('Excel文件生成失败:', error);
messageApi.error('Excel文件生成失败,请重试');
toast.danger('Excel文件生成失败,请重试');
}
}}
className={styles.downloadButton}
>
下载处理结果
</Button>
)}
</div>
) : null}
</Surface>
);
return (
<div className={styles.chat}>
<div className={styles.chatWithSidebar}>
{chatList}
{chatSender}
</div>
{stepsSidebar}
<div className="rc-invoice-workspace" ref={chatContainerRef}>
<ChatWorkspace
description="支持 ZIP、PDF 和图片批量识别,并按流程生成处理结果。"
files={attachedFiles}
inputValue={inputValue}
isLoading={agentLoading}
messages={messages}
renderAssistantContent={(item) => {
const content = item.message?.content?.trim();
return content ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizeHtml(marked.parse(preprocessLaTeX(content))),
}}
/>
) : (
<span>正在生成回复,请稍候...</span>
);
}}
suggestionGroups={suggestionGroups}
title={conversation ? 'Mia 发票助手' : '发票助手已就绪'}
userInfo={userInfo}
onFilesSelected={handleFilesSelected}
onInputChange={setInputValue}
onQuestion={(question) => onSubmit({ content: question })}
onRegenerate={regenerateLastResponse}
onRemoveFile={(file) =>
setAttachedFiles((current) =>
current.filter((item) => item.uid !== file.uid),
)
}
onStop={() => abortController.current?.abort()}
onSubmit={() => {
const content = inputValue.trim();
if (!content) return;
onSubmit({ content });
setInputValue('');
}}
onSuggestion={(item) => {
const content = item.content || item.description || item.label;
if (content) onSubmit({ content });
}}
/>
{stepsPanel}
</div>
);
});
......
import { Delete as DeleteIcon } from '@/components/AppIcons';
import AppShell from '@/components/AppShell';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import LoginModal from '@/components/LoginModal';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import Chat from '@/pages/Chat';
import {
getRecord,
......@@ -6,615 +11,41 @@ import {
updateConversation,
} from '@/services/DataService';
import {
AppstoreOutlined,
BorderlessTableOutlined,
CommentOutlined,
DeleteOutlined,
EditOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
PoweroffOutlined,
PushpinOutlined,
PushpinTwoTone,
} from '@ant-design/icons';
import { Conversations } from '@ant-design/x';
import { useQuery } from '@umijs/max';
import {
Avatar,
AlertDialog,
Button,
Drawer,
Dropdown,
Empty,
Input,
Modal,
Spin,
Tooltip,
message,
theme,
} from 'antd';
import { createStyles } from 'antd-style';
Spinner,
toast,
} from '@heroui/react';
import { useQuery } from '@umijs/max';
import { useEffect, useMemo, useRef, useState } from 'react';
import ApplicationSwitcherDialog from '../../components/ApplicationSwitcherDialog';
import LoginModal from '../../components/LoginModal';
const useStyle = createStyles(({ token, css, responsive }) => {
return {
shell: css`
height: 100dvh;
padding: 16px;
display: flex;
overflow: hidden;
background-image: linear-gradient(
rgba(125, 211, 252, 0.045) 1px,
transparent 1px
),
linear-gradient(90deg, rgba(125, 211, 252, 0.04) 1px, transparent 1px);
background-size: 42px 42px;
${responsive.xs} {
padding: 12px;
}
`,
frame: css`
width: 100%;
height: calc(100dvh - 32px);
display: flex;
gap: 16px;
border-radius: 36px;
position: relative;
overflow: hidden;
${responsive.xs} {
height: calc(100dvh - 24px);
border-radius: 26px;
}
`,
sidebar: css`
width: 316px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: radial-gradient(
circle at top left,
rgba(34, 211, 238, 0.15),
transparent 26%
),
linear-gradient(180deg, #020617 0%, #0f172a 58%, #111827 100%);
border: 1px solid rgba(125, 211, 252, 0.13);
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.34);
color: rgba(255, 255, 255, 0.95);
padding: 18px 16px 14px;
border-radius: 34px;
`,
sidebarCollapsed: css`
width: 108px;
padding-inline: 12px;
align-items: center;
`,
sidebarHeader: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 52px;
`,
brand: css`
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
`,
brandMark: css`
width: 44px;
height: 44px;
border-radius: 16px;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%);
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 19px;
font-weight: 700;
box-shadow: 0 14px 28px rgba(14, 165, 233, 0.26);
`,
brandText: css`
min-width: 0;
h1 {
margin: 0;
font-size: 18px;
line-height: 1.2;
color: #f8fbff;
letter-spacing: -0.01em;
}
p {
margin: 6px 0 0;
color: rgba(226, 232, 240, 0.86);
font-size: 12px;
line-height: 1.55;
max-width: 180px;
}
`,
collapseButton: css`
width: 40px;
height: 40px;
color: rgba(255, 255, 255, 0.94);
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.06);
&:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1) !important;
}
`,
newButton: css`
width: 100%;
height: 48px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%);
box-shadow: 0 16px 30px rgba(14, 165, 233, 0.28);
color: #ffffff;
font-weight: 600;
font-size: 16px;
&:hover {
color: #ffffff !important;
background: linear-gradient(
135deg,
#67e8f9 0%,
#3b82f6 100%
) !important;
}
`,
conversations: css`
flex: 1;
overflow-y: auto;
padding-right: 2px;
position: relative;
.ant-conversations-list {
margin: 0;
padding-top: 0;
padding-inline-start: 0;
}
.ant-conversations-list .ant-conversations-item {
padding-inline-start: 12px;
}
.ant-conversations-group {
margin-top: 0;
}
.ant-conversations-group-title {
color: rgba(248, 250, 252, 0.96) !important;
font-size: 14px;
line-height: 1.2;
padding: 2px 8px 8px;
font-weight: 700;
letter-spacing: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.4);
}
.ant-conversations-group-title * {
color: inherit !important;
}
.ant-conversations-item {
margin-bottom: 8px;
border-radius: 18px;
color: #f8fafc !important;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.13) 0%,
rgba(255, 255, 255, 0.08) 100%
);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
height: 60px;
min-height: 60px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-conversations-item:hover {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.12) 100%
);
border-color: rgba(125, 211, 252, 0.32);
transform: translateY(-1px);
}
.ant-conversations-item-active {
background: linear-gradient(
135deg,
rgba(34, 211, 238, 0.2) 0%,
rgba(37, 99, 235, 0.18) 100%
);
border-color: rgba(125, 211, 252, 0.42);
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.18),
0 14px 26px rgba(0, 0, 0, 0.24);
}
.ant-conversations-label,
.ant-conversations-icon,
.ant-conversations-menu-icon {
color: inherit !important;
}
.ant-conversations-label {
color: #f8fafc !important;
display: block;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
min-width: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.45);
}
.ant-conversations-label * {
color: inherit !important;
}
.ant-conversations-icon {
flex: 0 0 22px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 17px;
opacity: 1;
color: rgba(255, 255, 255, 0.96) !important;
}
.ant-conversations-item-active .ant-conversations-label {
color: #ffffff !important;
}
.ant-conversations-item:hover .ant-conversations-label,
.ant-conversations-item:hover .ant-conversations-label *,
.ant-conversations-item-active .ant-conversations-label,
.ant-conversations-item-active .ant-conversations-label * {
color: #ffffff !important;
}
.ant-conversations-item-active .ant-conversations-icon,
.ant-conversations-item-active .ant-conversations-menu-icon {
color: #ffffff !important;
}
.ant-conversations-menu-icon {
color: rgba(255, 255, 255, 0.86) !important;
opacity: 0.74;
padding: 6px;
border-radius: 10px;
transition: color 0.2s ease, background 0.2s ease, opacity 0.2s ease;
}
.ant-conversations-item:hover .ant-conversations-menu-icon {
opacity: 1;
}
.ant-conversations-menu-icon:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.12);
}
`,
emptyHistory: css`
padding-top: 12px;
.ant-empty-description {
color: rgba(226, 232, 240, 0.72);
}
`,
compactNav: css`
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-top: 8px;
overflow-y: auto;
`,
compactNavItem: css`
width: 100%;
display: flex;
justify-content: center;
`,
compactNavButton: css`
position: relative;
width: 58px;
height: 58px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(241, 245, 249, 0.92);
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
compactNavButtonActive: css`
background: linear-gradient(
135deg,
rgba(159, 18, 57, 0.52) 0%,
rgba(225, 29, 72, 0.26) 100%
);
border-color: rgba(251, 113, 133, 0.3);
box-shadow: 0 12px 24px rgba(127, 29, 29, 0.16);
`,
compactNavPin: css`
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #fb7185;
box-shadow: 0 0 0 4px rgba(251, 113, 133, 0.14);
`,
sidebarFooter: css`
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
`,
accountCard: css`
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
accountCardCollapsed: css`
flex-direction: column;
padding: 10px 8px;
gap: 8px;
`,
accountIdentity: css`
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
color: inherit;
cursor: pointer;
`,
accountMeta: css`
flex: 1;
min-width: 0;
strong {
display: block;
color: #f8fafc;
font-size: 14px;
line-height: 1.3;
}
span {
display: block;
color: rgba(226, 232, 240, 0.76);
font-size: 12px;
line-height: 1.3;
margin-top: 4px;
}
`,
applicationSwitcherButton: css`
width: 44px;
height: 44px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
border: 1px solid rgba(125, 211, 252, 0.34);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.06)
);
color: #67e8f9;
cursor: pointer;
box-shadow: 0 12px 22px rgba(14, 165, 233, 0.14);
transition: transform 0.2s ease, border-color 0.2s ease,
background 0.2s ease, color 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(125, 211, 252, 0.72);
background: rgba(14, 165, 233, 0.14);
color: #cffafe;
}
&:focus-visible {
outline: 2px solid rgba(34, 211, 238, 0.58);
outline-offset: 2px;
}
`,
contentShell: css`
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.72) 0%,
rgba(2, 6, 23, 0.78) 100%
);
border: 1px solid rgba(125, 211, 252, 0.12);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(20px);
border-radius: 32px;
overflow: hidden;
`,
mobileMenuButton: css`
display: none;
${responsive.lg} {
display: inline-flex;
position: absolute;
top: 14px;
right: 14px;
z-index: 4;
}
`,
mobileOnly: css`
display: none;
${responsive.lg} {
display: inline-flex;
}
`,
desktopSidebar: css`
display: flex;
${responsive.lg} {
display: none;
}
`,
body: css`
flex: 1;
min-height: 0;
padding: 0 20px 16px;
${responsive.xs} {
padding: 0 12px 12px;
}
`,
chatStage: css`
height: 100%;
border-radius: 28px;
position: relative;
background: radial-gradient(
circle at 18% 0%,
rgba(34, 211, 238, 0.18),
transparent 24%
),
radial-gradient(
circle at 84% 14%,
rgba(99, 102, 241, 0.16),
transparent 24%
),
linear-gradient(
180deg,
rgba(15, 23, 42, 0.7) 0%,
rgba(2, 6, 23, 0.78) 100%
);
border: 1px solid rgba(125, 211, 252, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.03), 0 28px 70px rgba(0, 0, 0, 0.26);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(
rgba(125, 211, 252, 0.04) 1px,
transparent 1px
),
linear-gradient(
90deg,
rgba(125, 211, 252, 0.035) 1px,
transparent 1px
);
background-size: 36px 36px;
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.88),
rgba(0, 0, 0, 0.16)
);
}
`,
drawerMask: css`
.ant-drawer-header,
.ant-drawer-body {
padding: 0;
}
.ant-drawer-content {
background: transparent;
box-shadow: none;
function getStoredUser() {
try {
const currentUser = localStorage.getItem('current_user');
return currentUser ? JSON.parse(currentUser) : null;
} catch {
return null;
}
`,
renameInput: css`
margin-top: 12px;
`,
};
});
function buildConversationItems(conversations = []) {
return conversations.map((c) => ({
key: c.name,
label: c.metadata.label || '新会话',
icon:
c.metadata.topic || c.metadata.topicConfig ? (
<BorderlessTableOutlined />
) : (
<CommentOutlined />
),
pinned: c.metadata.pinned,
group: '历史会话',
}));
}
function buildCompactConversationItems(conversations = []) {
return conversations.slice(0, 6).map((c) => ({
key: c.name,
label: c.metadata.label || '新会话',
icon:
c.metadata.topic || c.metadata.topicConfig ? (
<BorderlessTableOutlined />
) : (
<CommentOutlined />
),
pinned: c.metadata.pinned,
function buildConversationItems(conversations = []) {
return conversations.map((conversation) => ({
key: conversation.name,
label: conversation.metadata.label || '新会话',
pinned: conversation.metadata.pinned,
}));
}
export default () => {
const { token } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [userInfo, setUserInfo] = useState(
localStorage.getItem('current_user')
? JSON.parse(localStorage.getItem('current_user'))
: null,
);
const { styles, cx } = useStyle();
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userInfo, setUserInfo] = useState(getStoredUser);
const [currentConversation, setCurrentConversation] = useState(null);
const [renamingConversation, setRenamingConversation] = useState(null);
const [renamingConversationLabel, setRenamingConversationLabel] =
useState(null);
const [renamingModalOpen, setRenamingModalOpen] = useState(false);
useState('');
const [deletingConversation, setDeletingConversation] = useState(null);
const [applicationSwitcherOpen, setApplicationSwitcherOpen] = useState(false);
const [loading, setLoading] = useState(false);
const chatRef = useRef(null);
......@@ -622,22 +53,21 @@ export default () => {
const { data: conversations = [], refetch: refetchConversations } = useQuery({
queryKey: ['conversations', userInfo],
queryFn: async () => {
if (!userInfo) {
return [];
}
if (!userInfo) return [];
setLoading(true);
try {
const res = await queryConversations();
return res.records;
} catch (err) {
console.error(err);
if (err.response?.status === 401) {
const response = await queryConversations();
return response.records;
} catch (error) {
console.error(error);
if (error.response?.status === 401) {
localStorage.removeItem('current_user');
setUserInfo(null);
message.warning('请重新登录');
} else {
message.error('加载数据失败');
toast.danger('加载数据失败');
}
return [];
} finally {
setLoading(false);
}
......@@ -645,362 +75,214 @@ export default () => {
});
useEffect(() => {
document.title = 'AI 应用';
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = media.matches;
setIsMobile(mobile);
if (mobile) {
setCollapsed(false);
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
document.title = APPLICATION_DISPLAY_NAME;
}, []);
const conversationItems = useMemo(
() => buildConversationItems(conversations),
[conversations],
);
const compactConversationItems = useMemo(
() => buildCompactConversationItems(conversations),
[conversations],
);
const openConversation = async (name) => {
const conversation = await getRecord(name);
setCurrentConversation(conversation);
setMobileOpen(false);
};
const conversationMenu = (conversation) => ({
items: [
conversation.pinned
? {
key: 'unpin',
label: '已置顶',
type: 'primary',
icon: <PushpinTwoTone />,
onClick: async () => {
await updateConversation(conversation.key, {
pinned: false,
});
refetchConversations();
},
}
: {
key: 'pin',
label: '置顶',
icon: <PushpinOutlined />,
onClick: async () => {
const handleLogout = () => {
localStorage.removeItem('current_user');
setCurrentConversation(null);
setUserInfo(null);
};
const handlePinConversation = async (conversation) => {
await updateConversation(conversation.key, {
pinned: true,
pinned: !conversation.pinned,
});
refetchConversations();
},
},
{
label: '重命名',
key: 'rename',
icon: <EditOutlined />,
onClick: () => {
await refetchConversations();
};
const handleRenameConversation = (conversation) => {
setRenamingConversation(conversation);
setRenamingConversationLabel(conversation.label);
setRenamingModalOpen(true);
},
},
{
label: '删除',
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
onClick: async () => {
if (conversation.key === currentConversation?.name) {
};
const confirmRename = async () => {
if (!renamingConversation) return;
await updateConversation(renamingConversation.key, {
label: renamingConversationLabel,
});
await refetchConversations();
setRenamingConversation(null);
toast.success('会话名称已更新');
};
const confirmDelete = async () => {
if (!deletingConversation) return;
if (deletingConversation.key === currentConversation?.name) {
setCurrentConversation(null);
}
setLoading(true);
await removeConversation(conversation.key);
try {
await removeConversation(deletingConversation.key);
await refetchConversations();
setDeletingConversation(null);
toast.success('会话已删除');
} finally {
setLoading(false);
},
},
],
});
const sidebarContent = (
<div className={cx(styles.sidebar, collapsed && styles.sidebarCollapsed)}>
<div className={styles.sidebarHeader}>
{!collapsed && (
<div className={styles.brand}>
<div className={styles.brandMark}>AI</div>
<div className={styles.brandText}>
<h1>智能助手</h1>
<p>面向业务场景的统一 AI 对话工作台</p>
</div>
</div>
)}
{!isMobile && (
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed((value) => !value)}
className={styles.collapseButton}
/>
)}
</div>
<Button
onClick={async () => {
setCurrentConversation(null);
setMobileOpen(false);
}}
icon={<PlusOutlined />}
className={styles.newButton}
>
{!collapsed ? '新建会话' : null}
</Button>
{!collapsed ? (
<>
{conversations?.length > 0 ? (
<Conversations
items={conversationItems}
className={styles.conversations}
activeKey={currentConversation?.name}
onActiveChange={openConversation}
groupable
styles={{
item: {
padding: '0 8px',
},
}}
menu={conversationMenu}
/>
) : (
<div className={styles.conversations}>
<Empty
description="还没有历史会话"
image={Empty.PRESENTED_IMAGE_SIMPLE}
className={styles.emptyHistory}
/>
</div>
)}
</>
) : (
<div className={styles.compactNav}>
{compactConversationItems.length > 0 ? (
compactConversationItems.map((item) => (
<Tooltip key={item.key} title={item.label} placement="right">
<div className={styles.compactNavItem}>
<div
className={cx(
styles.compactNavButton,
currentConversation?.name === item.key &&
styles.compactNavButtonActive,
)}
onClick={() => openConversation(item.key)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openConversation(item.key);
}
}}
>
{item.icon}
{item.pinned ? (
<span className={styles.compactNavPin} />
) : null}
</div>
</div>
</Tooltip>
))
) : (
<Tooltip title="还没有历史会话" placement="right">
<div className={styles.compactNavItem}>
<div className={styles.compactNavButton}>
<CommentOutlined />
</div>
</div>
</Tooltip>
)}
</div>
)}
};
<div className={styles.sidebarFooter}>
{userInfo && (
<div
className={cx(
styles.accountCard,
collapsed && styles.accountCardCollapsed,
)}
>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'logout',
label: '退出登录',
icon: <PoweroffOutlined />,
onClick: () => {
localStorage.removeItem('current_user');
setUserInfo(null);
},
},
],
}}
>
<div className={styles.accountIdentity}>
<Avatar
size={collapsed ? 40 : 42}
src={userInfo?.avatar}
style={{
background: '#e0f2fe',
color: token.colorPrimary,
flexShrink: 0,
if (!userInfo) {
return (
<LoginModal
open
onError={() => {}}
onSuccess={(currentUser) => {
setUserInfo(currentUser);
window.setTimeout(() => toast.success('登录成功'), 0);
}}
>
{userInfo.display_name?.[0]}
</Avatar>
{!collapsed && (
<div className={styles.accountMeta}>
<strong>{userInfo.display_name || '当前用户'}</strong>
<span>账户设置与登录状态</span>
</div>
)}
</div>
</Dropdown>
<Tooltip title="应用切换" placement={collapsed ? 'right' : 'top'}>
<button
type="button"
aria-label="应用切换"
className={styles.applicationSwitcherButton}
onClick={() => setApplicationSwitcherOpen(true)}
>
<AppstoreOutlined />
</button>
</Tooltip>
</div>
)}
</div>
</div>
/>
);
}
return (
<Spin spinning={loading} tip="正在同步工作台">
<div className={styles.shell}>
<div className={styles.frame}>
<div className={styles.desktopSidebar}>{sidebarContent}</div>
<Drawer
placement="left"
width={332}
open={mobileOpen}
closeIcon={null}
onClose={() => setMobileOpen(false)}
className={styles.drawerMask}
>
{sidebarContent}
</Drawer>
<div className={styles.contentShell}>
<Button
type="default"
icon={<MenuUnfoldOutlined />}
onClick={() => setMobileOpen(true)}
className={styles.mobileMenuButton}
<>
<AppShell
activeKey={currentConversation?.name}
conversations={conversationItems}
open={sidebarOpen}
title={APPLICATION_DISPLAY_NAME}
userInfo={userInfo}
onDeleteConversation={setDeletingConversation}
onLogout={handleLogout}
onNewConversation={() => setCurrentConversation(null)}
onOpenApplicationSwitcher={() => setApplicationSwitcherOpen(true)}
onOpenChange={setSidebarOpen}
onOpenConversation={openConversation}
onPinConversation={handlePinConversation}
onRenameConversation={handleRenameConversation}
>
菜单
</Button>
<div className={styles.body}>
<div className={styles.chatStage}>
<section className="relative flex h-[calc(100dvh-7.5rem)] min-h-0 min-w-0 overflow-hidden">
<h2 className="sr-only">AI 对话</h2>
<Chat
conversation={currentConversation}
key={currentConversation?.name}
ref={chatRef}
userInfo={userInfo}
key={currentConversation?.name}
conversation={currentConversation}
onAddConversation={async (conversation, message) => {
await refetchConversations();
setCurrentConversation(conversation);
setMobileOpen(false);
if (message) {
setTimeout(() => {
chatRef.current?.submit(message);
}, 50);
setTimeout(() => chatRef.current?.submit(message), 50);
}
}}
/>
{loading ? (
<div
aria-label="正在同步工作台"
className="absolute inset-0 z-20 grid place-items-center"
>
<Spinner />
</div>
</div>
</div>
</div>
</div>
<Modal
title="重命名会话"
icon={null}
okText="确定"
cancelText="取消"
width={380}
open={renamingModalOpen}
onOk={async () => {
if (renamingConversation) {
await updateConversation(renamingConversation.key, {
label: renamingConversationLabel,
});
await refetchConversations();
setRenamingModalOpen(false);
}
}}
onCancel={() => {
setRenamingConversationLabel(null);
setRenamingModalOpen(false);
) : null}
</section>
</AppShell>
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={Boolean(renamingConversation)}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen) setRenamingConversation(null);
}}
>
<Modal.Container placement="center" size="md">
<Modal.Dialog className="rc-dialog-panel--md">
<Modal.Header>
<Modal.Heading>重命名会话</Modal.Heading>
</Modal.Header>
<Modal.Body>
<Input
size="large"
autoFocus
aria-label="会话名称"
placeholder="输入会话名称"
value={renamingConversationLabel}
onChange={(e) => {
setRenamingConversationLabel(e.target.value);
}}
className={styles.renameInput}
variant="secondary"
onChange={(event) =>
setRenamingConversationLabel(event.target.value)
}
/>
</Modal>
</Modal.Body>
<Modal.Footer className="rc-dialog-footer">
<Button
variant="secondary"
onPress={() => setRenamingConversation(null)}
>
取消
</Button>
<Button
isDisabled={!renamingConversationLabel.trim()}
variant="primary"
onPress={confirmRename}
>
确定
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
<AlertDialog.Root>
<AlertDialog.Trigger
aria-hidden="true"
className="sr-only"
tabIndex={-1}
/>
<AlertDialog.Backdrop
isDismissable
isKeyboardDismissDisabled={false}
isOpen={Boolean(deletingConversation)}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen) setDeletingConversation(null);
}}
>
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog className="rc-dialog-panel--md">
<AlertDialog.Header>
<AlertDialog.Icon status="danger">
<DeleteIcon size={20} />
</AlertDialog.Icon>
<AlertDialog.Heading>删除会话</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
删除后无法恢复,确定删除“{deletingConversation?.label}”吗?
</AlertDialog.Body>
<AlertDialog.Footer>
<Button
variant="secondary"
onPress={() => setDeletingConversation(null)}
>
取消
</Button>
<Button variant="danger" onPress={confirmDelete}>
删除
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog.Root>
<ApplicationSwitcherDialog
open={applicationSwitcherOpen}
onClose={() => setApplicationSwitcherOpen(false)}
userInfo={userInfo}
onClose={() => setApplicationSwitcherOpen(false)}
/>
<LoginModal
open={!userInfo}
onSuccess={(currentUser) => {
setUserInfo(currentUser);
message.success('登录成功');
}}
onError={(errorMessage) => message.error(errorMessage)}
/>
</Spin>
</>
);
};
}
.container {
padding-top: 80px;
}
@import 'tailwindcss';
@import '@heroui/styles';
@import '@heroui-pro/react/css';
@import './design-system/theme.css';
@import './design-system/tokens.css';
@import './design-system/shell.css';
@theme {
--font-inter: 'Inter', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
sans-serif;
}
:root {
--font-inter: 'Inter', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme='dark'] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
color-scheme: inherit;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
--rc-login-card-brand-margin-bottom: 2rem;
--rc-login-card-brand-title-size: 2rem;
--rc-login-card-brand-title-weight: var(--rc-login-brand-weight);
--rc-login-control-height: 2.5rem;
--rc-login-submit-height: 2.875rem;
--rc-login-submit-margin-top: 0.875rem;
--rc-login-field-filled-background: #fbf1f1;
--rc-login-field-filled-background-hover: #faeaea;
--rc-login-field-autofill-background: #fbf1f1;
--rc-login-field-border-hover: rgb(158 27 27 / 24%);
--rc-login-field-focus-ring: rgb(158 27 27 / 16%);
}
body .login-brand img {
width: var(--rc-login-brand-logo-size) !important;
height: var(--rc-login-brand-logo-size) !important;
filter: none !important;
}
body .login-card-brand {
justify-content: center;
gap: var(--rc-login-card-brand-gap) !important;
margin-bottom: var(--rc-login-card-brand-margin-bottom);
text-align: center;
}
body .login-card-brand strong {
color: var(--rc-text-primary);
font-size: var(--rc-login-card-brand-title-size);
font-weight: var(--rc-login-card-brand-title-weight);
line-height: 1.15;
letter-spacing: 0;
white-space: nowrap;
}
body .login-card [data-slot='card-header'] {
display: none !important;
}
body .login-card [data-slot='card-content'] {
padding: 2.25rem !important;
}
body
.login-form
.login-field
:is(
.input,
.input--secondary,
.login-input,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
) {
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field
:is(input, .login-input, input.input--secondary, input[data-slot='input']) {
line-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value='true']
:is(
.input input,
[data-slot='input-wrapper'] input,
[data-slot='control'] input
) {
background: transparent !important;
background-color: transparent !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot='input']
)[data-filled='true'],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: transparent !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
):hover,
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
)[data-hovered='true'] {
border-color: var(--rc-login-field-border-hover) !important;
background: var(--rc-login-field-filled-background-hover) !important;
background-color: var(--rc-login-field-filled-background-hover) !important;
}
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
):focus,
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
):focus-within,
body
.login-form
.login-field[data-has-value='true']
:is(
.input,
.input--secondary,
[data-slot='input'],
[data-slot='input-wrapper'],
[data-slot='control']
)[data-focused='true'] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
):focus,
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
)[data-focused='true'] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot='input']
)[data-filled='true']:focus,
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot='input']
)[data-filled='true'][data-focused='true'],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(
:placeholder-shown
):focus,
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot='input']):not(
:placeholder-shown
)[data-focused='true'] {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
:is(.input, [data-slot='input-wrapper'], [data-slot='control']):has(
input:-webkit-autofill
),
body
.login-form
:is(.input, [data-slot='input-wrapper'], [data-slot='control']):has(
input:autofill
) {
--input-bg: var(--rc-login-field-autofill-background) !important;
--input-bg-hover: var(--rc-login-field-autofill-background) !important;
--input-bg-focus: var(--rc-login-field-autofill-background) !important;
background: var(--rc-login-field-autofill-background) !important;
background-color: var(--rc-login-field-autofill-background) !important;
}
body .login-form input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--rc-login-field-autofill-background)
inset !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
caret-color: var(--rc-text-primary);
}
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
)[data-filled='true'],
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
),
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
):focus,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
):focus-visible,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot='input']):not(
:placeholder-shown
)[data-focused='true'],
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
):-webkit-autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
):-webkit-autofill:hover,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
):-webkit-autofill:focus,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
):autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot='input']
):autofill:focus {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
appearance: none;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
background-image: none !important;
background-clip: padding-box !important;
caret-color: var(--rc-text-primary);
box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
transition: background-color 9999s ease-out, color 9999s ease-out;
}
body .login-submit-button {
height: var(--rc-login-submit-height) !important;
min-height: var(--rc-login-submit-height) !important;
margin-top: var(--rc-login-submit-margin-top) !important;
border-radius: var(--rc-button-radius, 999px) !important;
}
.login-brand-logo {
display: grid;
width: var(--rc-login-brand-logo-size);
height: var(--rc-login-brand-logo-size);
place-items: center;
}
.login-brand-logo .rc-brand-logo,
.rc-sidebar-brand-mark .rc-sidebar-logo {
grid-area: 1 / 1;
object-fit: contain;
}
.rc-brand-logo--dark,
.rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: none;
}
.dark .rc-brand-logo--light,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--light {
display: none;
}
.dark .rc-brand-logo--dark,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: block;
}
body .login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 0.75rem;
width: 100%;
}
body .login-captcha-button {
width: 100%;
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
border: 1px solid var(--rc-button-secondary-border) !important;
border-radius: var(--rc-radius-control) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
box-shadow: none !important;
}
body .login-captcha-button .captcha-image {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
place-items: center;
overflow: hidden;
}
body .login-captcha-button .captcha-image :is(svg, img) {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 1.875rem;
margin: 0;
}
.dark body .login-captcha-button,
body .dark .login-captcha-button,
body [data-theme='dark'] .login-captcha-button {
border-color: var(--rc-button-secondary-border) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
}
.dark .login-page,
body .dark .login-page,
body.dark .login-page,
body [data-theme='dark'] .login-page {
--rc-login-field-filled-background: color-mix(
in oklab,
var(--rc-brand-primary) 14%,
var(--rc-field-background)
);
--rc-login-field-filled-background-hover: color-mix(
in oklab,
var(--rc-brand-primary) 18%,
var(--rc-field-background)
);
--rc-login-field-autofill-background: color-mix(
in oklab,
var(--rc-brand-primary) 16%,
var(--rc-field-background)
);
--rc-login-field-border-hover: color-mix(
in oklab,
var(--rc-brand-primary) 42%,
var(--rc-border-default)
);
--rc-login-field-focus-ring: color-mix(
in oklab,
var(--rc-brand-primary) 22%,
transparent
);
}
.rc-chat-workspace {
display: flex;
height: 100%;
width: 100%;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 1rem;
overflow: hidden;
}
.rc-chat-conversation {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.rc-chat-conversation-content {
width: min(100%, 56rem);
margin-inline: auto;
gap: 1rem;
padding-block: 2rem 1rem;
}
.rc-chat-conversation-content--empty {
width: min(100%, 48rem);
}
.rc-chat-context {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
}
.rc-chat-context {
align-items: flex-start;
flex-direction: column;
}
.rc-chat-suggestions {
width: 100%;
}
.rc-chat-suggestion-items {
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-chat-suggestion-items--single {
grid-template-columns: minmax(0, 1fr);
}
.rc-chat-composer-stack {
display: grid;
width: min(100%, 48rem);
min-height: 0;
flex: none;
margin-inline: auto;
gap: 0.75rem;
}
.rc-invoice-workspace {
display: grid;
height: 100%;
width: 100%;
min-width: 0;
min-height: 0;
flex: 1;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 1.5rem;
overflow: hidden;
}
.rc-invoice-steps {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
}
@media (max-width: 1023px) {
.rc-invoice-workspace {
grid-template-columns: minmax(0, 1fr);
}
.rc-invoice-steps {
order: -1;
}
}
@media (max-width: 640px) {
.rc-chat-suggestion-items {
grid-template-columns: minmax(0, 1fr);
}
}
import { useCallback, useMemo, useRef, useState } from 'react';
let requestId = 0;
class ChatAgent {
constructor(request, onStateChange) {
this.requestHandler = request;
this.onStateChange = onStateChange;
this.pending = new Set();
}
request(info, callbacks) {
const id = requestId++;
this.pending.add(id);
this.onStateChange();
const active = (callback) => (value) => {
if (this.pending.has(id)) callback?.(value);
};
const finish = (callback) => (value) => {
if (!this.pending.has(id)) return;
callback?.(value);
this.pending.delete(id);
this.onStateChange();
};
this.requestHandler(info, {
onError: finish(callbacks.onError),
onStream: active(callbacks.onStream),
onSuccess: finish(callbacks.onSuccess),
onUpdate: active(callbacks.onUpdate),
});
}
isRequesting() {
return this.pending.size > 0;
}
}
export function useChatAgent({ request }) {
const requestRef = useRef(request);
const [, setVersion] = useState(0);
requestRef.current = request;
const agent = useMemo(
() =>
new ChatAgent(
(...args) => requestRef.current(...args),
() => setVersion((version) => version + 1),
),
[],
);
return [agent];
}
function normalizeMessages(messages = []) {
return messages.map((item, index) =>
item?.message
? item
: { id: `default_${index}`, message: item, status: 'local' },
);
}
export function useChat({
agent,
requestFallback,
resolveAbortController,
transformMessage,
}) {
const idRef = useRef(0);
const messagesRef = useRef([]);
const [messages, setMessageState] = useState([]);
const setMessages = useCallback((value) => {
setMessageState((previous) => {
const next = normalizeMessages(
typeof value === 'function' ? value(previous) : value,
);
messagesRef.current = next;
return next;
});
}, []);
const createMessage = useCallback(
(message, status) => ({
id: `msg_${idRef.current++}`,
message,
status,
}),
[],
);
const onRequest = useCallback(
(requestParams) => {
const { message, ...otherRequestParams } = requestParams;
const userMessage = createMessage(message, 'local');
messagesRef.current = [...messagesRef.current, userMessage];
setMessageState(messagesRef.current);
let updatingMessageId = null;
const getRequestMessages = () =>
messagesRef.current
.filter((item) => !['loading', 'error'].includes(item.status))
.map((item) => item.message);
const updateMessage = (status, chunk, chunks) => {
const current = messagesRef.current.find(
(item) => item.id === updatingMessageId,
);
if (!current) {
const nextMessage = createMessage(
transformMessage({ chunk, chunks, status }),
status,
);
updatingMessageId = nextMessage.id;
messagesRef.current = [...messagesRef.current, nextMessage];
} else {
messagesRef.current = messagesRef.current.map((item) =>
item.id === updatingMessageId
? {
...item,
message: transformMessage({
chunk,
chunks,
originMessage: item.message,
status,
}),
status,
}
: item,
);
}
setMessageState(messagesRef.current);
};
agent.request(
{
message,
messages: getRequestMessages(),
...otherRequestParams,
},
{
onUpdate: (chunk) => updateMessage('loading', chunk, []),
onSuccess: (chunks) => updateMessage('success', undefined, chunks),
onError: async (error) => {
const fallback = await requestFallback(message, {
error,
messages: getRequestMessages(),
});
messagesRef.current = [
...messagesRef.current.filter(
(item) => item.id !== updatingMessageId,
),
createMessage(fallback, 'error'),
];
setMessageState(messagesRef.current);
},
onStream: resolveAbortController,
},
);
},
[
agent,
createMessage,
requestFallback,
resolveAbortController,
transformMessage,
],
);
return { messages, onRequest, setMessages };
}
export function createSseStream(readableStream) {
if (!(readableStream instanceof ReadableStream)) {
throw new Error('响应正文不是可读取流');
}
return {
async *[Symbol.asyncIterator]() {
const reader = readableStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const parts = buffer.split(/\r?\n\r?\n/);
buffer = parts.pop() || '';
for (const part of parts) {
const event = {};
part.split(/\r?\n/).forEach((line) => {
const separator = line.indexOf(':');
if (separator <= 0) return;
event[line.slice(0, separator)] = line.slice(separator + 1);
});
if (Object.keys(event).length) yield event;
}
if (done) {
if (buffer.trim()) {
const event = {};
buffer.split(/\r?\n/).forEach((line) => {
const separator = line.indexOf(':');
if (separator <= 0) return;
event[line.slice(0, separator)] = line.slice(separator + 1);
});
if (Object.keys(event).length) yield event;
}
break;
}
}
},
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment