Compare commits

...
Author SHA1 Message Date
疯狂的狮子Li d1e11acb4f 🦁🦁🦁发布 6.0.0 正式版 大版本发布!!! 2026-07-30 11:02:03 +08:00
疯狂的狮子Li 0697f1fafb update 包管理 降级到pnpm 10版本 兼容低版本nodejs 2026-07-29 09:26:08 +08:00
疯狂的狮子Li 903c34801a update 升级依赖 2026-07-27 17:24:28 +08:00
疯狂的狮子Li b1661081d6 update 升级 全部依赖版本与适配性代码修改 2026-07-16 18:39:41 +08:00
疯狂的狮子Li 830eacbf02 update 优化将自行实现的ai聊天室页面改为内嵌 snail-ai 自带的聊天室页面 2026-07-09 13:22:42 +08:00
疯狂的狮子Li 497a317e90 update 优化 适配多前端 skill 内容 2026-07-06 16:15:41 +08:00
疯狂的狮子Li e74984e0e0 fix 修复 用户上传头像后无法编辑问题 头像url改为独立字段避免影响 2026-07-03 17:00:39 +08:00
疯狂的狮子Li a1069624e7 update 优化 关闭固定header的时候 header与logo部分出现的样式问题 2026-06-30 11:50:55 +08:00
疯狂的狮子Li 836cc40d43 update 补充漏提交文件 2026-06-30 11:41:04 +08:00
疯狂的狮子Li 512a2ab86e add 新增 页面表单全高表格内部数据滚动功能 2026-06-30 11:35:19 +08:00
疯狂的狮子Li b4f5fe82a2 update 依赖整体升级 2026-06-29 13:36:54 +08:00
疯狂的狮子Li 8f217a2cc8 update 优化 后端异常500 前端错误弹窗 2026-06-29 11:06:15 +08:00
疯狂的狮子Li 516efedfa0 update 优化 增加首页菜单icon图标 2026-06-26 11:59:06 +08:00
疯狂的狮子Li 6c7ec5d3f1 fix 修复 菜单收起按钮与滑动块样式问题 2026-06-25 18:15:49 +08:00
疯狂的狮子Li a1ab12e70c fix 修复 菜单收起样式问题 2026-06-25 18:08:23 +08:00
疯狂的狮子Li 2b8793199d fix 修复 push 模块 sse/ws 浏览器多标签页互挤掉线问题 2026-06-25 17:23:44 +08:00
疯狂的狮子Li 51cf3e2b19 update 优化 处理一些代码生成模板的一些写法问题 2026-06-24 17:33:05 +08:00
疯狂的狮子Li d155f09e79 fix 修复 代码生成模板后缀漏改问题 2026-06-23 14:58:29 +08:00
疯狂的狮子Li d10da5d62a update 优化 登录页样式 2026-06-22 12:14:51 +08:00
61 changed files with 2919 additions and 3102 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ description: 前端 API 与类型定义专家。用于当前 plus-ui-react 项
3. 能明确写出类型时,不要偷懒用 `any`。
4. 如果当前模块已有特殊返回结构或聚合导出,继续保持一致。
5. 不创建页面,不改路由,除非用户明确要求。
6. 如果用户要求维护代码生成模板,优先同步当前仓库 `gen/api.ts.vm`、`gen/types.ts.vm`,必要时再看 boot4 后端 generator 字段定义。
6. 如果用户要求维护代码生成模板,优先同步当前仓库 `gen/api.ts.ftl`、`gen/types.ts.ftl`,必要时再看 boot4 后端 generator 字段定义。
## API 规则
@@ -45,7 +45,7 @@ description: 前端 API 与类型定义专家。用于当前 plus-ui-react 项
## gen 模板规则
- `gen/api.ts.vm` 和 `gen/types.ts.vm` 是当前 React 项目内置模板,优先于外部后端模板。
- `gen/api.ts.ftl` 和 `gen/types.ts.ftl` 是当前 React 项目内置模板,优先于外部后端模板。
- 保持 Velocity 变量、宏和输出路径约定,不要破坏后端 generator 可替换变量。
- 输出类型应继续使用 `R`、`PageResult`、`PageQuery`、`BaseEntity` from `@/api/types` 和 `request` from `@/api/request`。
- 不输出 Vue 版 `AxiosPromise`、`@/utils/request` 或 `src/views` 约定。
@@ -56,5 +56,5 @@ description: 前端 API 与类型定义专家。用于当前 plus-ui-react 项
- 类型是否覆盖接口真实结构。
- 是否不必要地把类型写宽了。
- 是否保留了当前模块的命名和导出风格。
- 是否同步维护了当前项目内置 `gen/*.vm` 模板中对应 API/types 输出。
- 是否同步维护了当前项目内置 `gen/*.ftl` 模板中对应 API/types 输出。
- 是否误用了 Vue 版 `AxiosPromise`、`@/utils/request` 或 `src/views` 约定。
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: frontend-crud-coding
description: 前端总入口。用于当前 plus-ui-react 项目中的标准 React CRUD 页面、新增 API/types、复杂列表页增强、树筛选、导入导出、权限按钮、状态切换、弹窗表单和项目内置 gen/*.vm React 代码生成模板维护任务,并根据任务类型选择合适的前端子 agent。
description: 前端总入口。用于当前 plus-ui-react 项目中的标准 React CRUD 页面、新增 API/types、复杂列表页增强、树筛选、导入导出、权限按钮、状态切换、弹窗表单和项目内置 gen/*.ftl React 代码生成模板维护任务,并根据任务类型选择合适的前端子 agent。
---
你是当前 plus-ui-react 前端项目的总入口 agent。
@@ -10,13 +10,13 @@ description: 前端总入口。用于当前 plus-ui-react 项目中的标准 Rea
1. 如果是新增标准 CRUD 页面、补 `src/api`、`types.ts`、`index.tsx`,优先使用 `frontend-crud-page.md`。
2. 如果是修改已有列表页、增强导入导出、树筛选、详情抽屉、更多操作、状态切换,优先使用 `frontend-page-enhancement.md`。
3. 如果只改接口层和类型定义,优先使用 `frontend-api-types.md`。
4. 如果是维护代码生成模板,优先读取当前仓库 `gen/api.ts.vm`、`gen/types.ts.vm`、`gen/index.tsx.vm`、`gen/index-tree.tsx.vm`,再按 React 项目真实工具和页面规则修改模板。
4. 如果是维护代码生成模板,优先读取当前仓库 `gen/api.ts.ftl`、`gen/types.ts.ftl`、`gen/index.tsx.ftl`、`gen/index-tree.tsx.ftl`,再按 React 项目真实工具和页面规则修改模板。
通用要求:
- 先读当前目录下最近似页面和 API,再动代码。
- 优先参考 `.codex/skills/frontend-crud-coding/SKILL.md` 和 `references/frontend.md`,保持 Claude agent 与 Codex skill 规则一致。
- 冲突时优先相信当前项目真实页面,其次是公共组件、hooks、工具和请求封装,再其次是当前仓库 `gen/*.vm` React 模板,最后才是 Vue 参考项目和关联后端工程 generator 模板。
- 冲突时优先相信当前项目真实页面,其次是公共组件、hooks、工具和请求封装,再其次是当前仓库 `gen/*.ftl` React 模板,最后才是 Vue 参考项目和关联后端工程 generator 模板。
- 默认直接产出可落地代码,而不是只给抽象建议。
- 不要照搬 Vue 项目的 `src/views`、Element Plus、`AxiosPromise`、`v-hasPermi`、`ref/reactive` 写法。
@@ -30,7 +30,7 @@ description: 前端总入口。用于当前 plus-ui-react 项目中的标准 Rea
- 多选通过 `useTableSelection`,导出通过 `useTableExport`。
- 行操作优先使用 `RowActions` 和 `@ant-design/icons`。
- 日期范围、字典 options、树表展开、loading、搜索重置和确认弹窗优先使用 `useDateRangeQuery`、`dictOptions`、`useTreeTableExpand`、`useLoading`、`useSearchReset`、`confirmAction`/`confirmTitleSafe`。
- `gen/index.tsx.vm`、`gen/index-tree.tsx.vm` 内容必须生成 React TSX 页面。
- `gen/index.tsx.ftl`、`gen/index-tree.tsx.ftl` 内容必须生成 React TSX 页面。
验证要求:
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: frontend-crud-page
description: 前端标准 React CRUD 页面专家。用于当前 plus-ui-react 项目中的新建列表页、弹窗表单页、标准 API/types/index.tsx 骨架、项目内置 gen/*.vm React 模板维护,以及后端 generator 字段到 React + ProTable 项目风格的落地任务。
description: 前端标准 React CRUD 页面专家。用于当前 plus-ui-react 项目中的新建列表页、弹窗表单页、标准 API/types/index.tsx 骨架、项目内置 gen/*.ftl React 模板维护,以及后端 generator 字段到 React + ProTable 项目风格的落地任务。
---
你负责当前 plus-ui-react 项目中的标准 CRUD 页面实现。
@@ -9,7 +9,7 @@ description: 前端标准 React CRUD 页面专家。用于当前 plus-ui-react
1. 先看当前模块最近似页面。
2. 再参考 `.codex/skills/frontend-crud-coding/references/frontend.md`。
3. 维护或新增生成能力时,优先参考当前仓库 `gen/api.ts.vm`、`gen/types.ts.vm`、`gen/index.tsx.vm`、`gen/index-tree.tsx.vm`。
3. 维护或新增生成能力时,优先参考当前仓库 `gen/api.ts.ftl`、`gen/types.ts.ftl`、`gen/index.tsx.ftl`、`gen/index-tree.tsx.ftl`。
4. 必要时参考关联后端工程 generator 模板确认接口、字段、权限和导出能力。
5. 默认同时维护:
`src/api/<module>/<business>/index.ts`
@@ -58,7 +58,7 @@ description: 前端标准 React CRUD 页面专家。用于当前 plus-ui-react
## gen 模板规则
- `gen/index.tsx.vm`、`gen/index-tree.tsx.vm` 内容生成 React TSX。
- `gen/index.tsx.ftl`、`gen/index-tree.tsx.ftl` 内容生成 React TSX。
- 保持 Velocity 变量、宏和后端 generator 可识别结构,不为了格式美化破坏模板语法。
- 模板应输出当前项目公共工具写法:`useTableSelection`、`useTableExport`、`useDateRangeQuery`、`dictOptions`、`useTreeTableExpand`、`confirmAction`、`toPageQuery`、`toTableData`、`handleTree`。
- 不把 Vue `src/views`、Element Plus、`AxiosPromise`、`v-hasPermi` 写入模板。
+1 -1
View File
@@ -46,7 +46,7 @@ description: 复杂前端页面增强专家。用于修改当前 plus-ui-react
- 如果页面已有导出参数缓存,继续通过 `updateExportParams` 更新。
- 如果页面已有树筛选状态,查询参数要合并该状态,不要覆盖掉。
- 如果页面已有特殊禁用规则、超级管理员保护或业务状态判断,必须保留。
- 如果增强的是标准 CRUD 能力或公共工具约定,要评估当前仓库 `gen/*.vm` 是否也应同步更新。
- 如果增强的是标准 CRUD 能力或公共工具约定,要评估当前仓库 `gen/*.ftl` 是否也应同步更新。
## 自检
+4 -4
View File
@@ -17,8 +17,8 @@ description: 在当前 plus-ui-react 前端项目中按真实 React + TypeScript
- 系统复杂页优先看 `src/pages/system/user/index.tsx`、`system/role`、`system/post`、`system/config`。
- workflow 业务页优先看 `src/pages/workflow/*` 与 `src/api/workflow/*` 同类页面。
4. 新增或维护代码生成能力时,优先阅读当前仓库 `gen/api.ts.ftl`、`gen/types.ts.ftl`、`gen/index.tsx.ftl`、`gen/index-tree.tsx.ftl`。这些是 React 版内置 FreeMarker 生成模板,页面模板必须输出当前项目 React/TSX 风格。
5. 需要从 Vue 版本迁移经验时,参考 `D:\git-sources\Plus相关\plus-ui-new\.codex\skills\frontend-crud-coding` 和 `.claude\agents`,只吸收任务分型、增量修改和自检原则。
6. 新增标准页面前,可以对照后端工程 `D:\git-sources\Plus相关\RuoYi-Vue-Plus-boot4\ruoyi-modules\ruoyi-gen\src\main\resources\fm` 确认字段、权限和导出能力,但输出和 `gen/` 模板必须保持 React 项目风格。
5. 需要从 Vue 版本迁移经验时,参考 Gitee 项目 `https://gitee.com/JavaLionLi/plus-ui` 的 `6.X-Vue` 分支,目录为 `.codex/skills/frontend-crud-coding` 和 `.claude/agents`,只吸收任务分型、增量修改和自检原则。
6. 新增标准页面前,可以对照 Gitee 项目 `https://gitee.com/dromara/RuoYi-Vue-Plus` 的 `6.X` 分支,目录为 `ruoyi-modules/ruoyi-gen/src/main/resources/fm`,确认字段、权限和导出能力,但输出和 `gen/` 模板必须保持 React 项目风格。
7. 新增代码时通常同步维护 `src/api/<module>/<business>/index.ts`、`types.ts`、`src/pages/<module>/<business>/index.tsx`;如果是生成模板优化,同步维护 `gen/` 对应 `.ftl`。
8. 增强已有页面时只做增量修改,保留原页面的树筛选、导入导出、列显隐、权限、字典、弹窗、抽屉和路由跳转能力。
9. 修改完成后按影响范围运行验证:优先 `pnpm exec tsc --noEmit` 或 `pnpm lint`;大范围页面、公共组件、构建配置变更再跑 `pnpm build`。
@@ -36,8 +36,8 @@ description: 在当前 plus-ui-react 前端项目中按真实 React + TypeScript
1. 目标目录下最近似页面、API、types 的真实实现。
2. 当前项目公共 hooks、组件、工具、样式和请求封装约定。
3. 当前项目内置 `gen/*.ftl` React 代码生成模板。
4. Vue 参考项目 `.codex` / `.claude` 中的任务分型和工作流。
5. 关联后端工程 generator 模板。
4. Gitee 项目 `plus-ui` 的 `6.X-Vue` 分支 `.codex` / `.claude` 中的任务分型和工作流。
5. Gitee 项目 `RuoYi-Vue-Plus` 的 `6.X` 分支 generator 模板。
6. 通用 React / Ant Design ProComponents 习惯。
也就是说:
-4
View File
@@ -92,7 +92,3 @@ export function del${BusinessName}(${pkColumn.javaField}: string | number | Arra
method: 'delete'
});
}
+4 -7
View File
@@ -62,6 +62,7 @@ import { useDict } from '@/hooks/useDict';
<#if enableExport>
import { useTableExport } from '@/hooks/useTableExport';
</#if>
import { useTableScroll } from '@/hooks/useTableScroll';
import { useTreeTableExpand } from '@/hooks/useTreeTableExpand';
import { useUserStore } from '@/stores/userStore';
<#if needDict>
@@ -103,6 +104,7 @@ const ${statusField}InactiveValue = <#if statusColumn.javaType == "Boolean">fals
</#if>
export default function ${BusinessName}Page() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1000);
const [form] = Form.useForm<${BusinessName}Form>();
const userInfo = useUserStore(state => state.userInfo);
<#if needDict>
@@ -308,7 +310,7 @@ export default function ${BusinessName}Page() {
actionRef={actionRef}
rowKey="${treeCode}"
columns={columns}
scroll={{ x: 1000 }}
scroll={tableScroll}
pagination={false}
search={{ labelWidth: 90 }}
expandable={{
@@ -365,8 +367,7 @@ export default function ${BusinessName}Page() {
<ProFormText name="${pkColumn.javaField}" hidden />
<#list columns as column>
<#if (column.insert || column.edit) && !column.pk>
<#assign field = column.javaField>
<#if field == treeParentCode>
<#if column.javaField == treeParentCode>
<ProFormTreeSelect
name="${column.javaField}"
label="${column.columnLabel}"
@@ -421,7 +422,3 @@ export default function ${BusinessName}Page() {
</PageContainer>
);
}
+3 -6
View File
@@ -64,6 +64,7 @@ import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
<#if enableExport>
import { useTableExport } from '@/hooks/useTableExport';
</#if>
import { useTableScroll } from '@/hooks/useTableScroll';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useUserStore } from '@/stores/userStore';
<#if needDict>
@@ -90,6 +91,7 @@ const ${statusField}InactiveValue = <#if statusColumn.javaType == "Boolean">fals
</#if>
export default function ${BusinessName}Page() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1000);
const [form] = Form.useForm<${BusinessName}Form>();
const userInfo = useUserStore(state => state.userInfo);
<#if needDict>
@@ -292,7 +294,7 @@ const { applyDateRange: apply${column.capJavaField}DateRange } = useDateRangeQue
actionRef={actionRef}
rowKey="${pkColumn.javaField}"
columns={columns}
scroll={{ x: 1000 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
@@ -359,7 +361,6 @@ query = apply${column.capJavaField}DateRange(query, params.${column.javaField}Ra
<ProFormText name="${pkColumn.javaField}" hidden />
<#list columns as column>
<#if (column.insert || column.edit) && !column.pk>
<#assign field = column.javaField>
<#if column.htmlType == "input">
<ProFormText name="${column.javaField}" label="${column.columnLabel}" <#if column.required>rules={[{ required: true, message: '${column.columnLabel}不能为空' }]}</#if> />
<#elseif column.htmlType == "textarea">
@@ -403,7 +404,3 @@ query = apply${column.capJavaField}DateRange(query, params.${column.javaField}Ra
</PageContainer>
);
}
-3
View File
@@ -52,6 +52,3 @@ export interface ${BusinessName}Query<#if !table.tree> extends PageQuery</#if> {
*/
params?: Record<string, unknown>;
}
+18 -17
View File
@@ -1,9 +1,10 @@
{
"name": "plus-ui-react",
"version": "6.0.0-BETA",
"version": "6.0.0",
"private": true,
"description": "React + Ant Design version of RuoYi-Vue-Plus UI",
"type": "module",
"packageManager": "pnpm@10.34.5",
"scripts": {
"build": "pnpm build:prod",
"build:dev": "cross-env UMI_ENV=development BABEL_POLYFILL=none max build",
@@ -17,18 +18,18 @@
"start": "cross-env UMI_ENV=development MOCK=none BABEL_POLYFILL=none max dev"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@ant-design/icons": "^6.3.2",
"@ant-design/pro-components": "3.1.12-0",
"@ant-design/x": "^2.7.0",
"@ant-design/x": "^2.8.0",
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.101.0",
"@umijs/max": "^4.6.61",
"@wangeditor-next/editor": "^5.7.10",
"@wangeditor-next/editor-for-react": "^2.0.2",
"@tanstack/react-query": "^5.101.4",
"@umijs/max": "^4.6.82",
"@wangeditor-next/editor": "^5.7.16",
"@wangeditor-next/editor-for-react": "^2.0.3",
"ahooks": "^3.9.7",
"antd": "^6.4.3",
"antd": "^6.5.2",
"antd-style": "^4.1.0",
"axios": "1.17.0",
"axios": "1.18.1",
"browser-image-compression": "^2.0.2",
"clsx": "^2.1.1",
"crypto-js": "4.2.0",
@@ -37,9 +38,9 @@
"echarts-for-react": "^3.0.6",
"jsencrypt": "3.5.4",
"nprogress": "0.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^5.5.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-easy-crop": "^6.2.3",
"react-json-view-lite": "^2.5.0",
"react-syntax-highlighter": "^16.1.1",
"screenfull": "^6.0.2",
@@ -47,20 +48,20 @@
},
"devDependencies": {
"@types/crypto-js": "4.2.2",
"@types/node": "^25.9.2",
"@types/node": "^26.1.1",
"@types/nprogress": "0.2.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@umijs/lint": "^4.6.61",
"@umijs/lint": "^4.6.82",
"@umijs/max-plugin-openapi": "^2.0.3",
"cross-env": "^10.1.0",
"oxfmt": "^0.53.0",
"oxlint": "^1.68.0",
"oxfmt": "^0.60.0",
"oxlint": "^1.75.0",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=20.0.0",
"node": ">=20.19.0",
"pnpm": ">=10.0.0"
}
}
+2269 -2034
View File
File diff suppressed because it is too large Load Diff
+3 -143
View File
@@ -1,62 +1,6 @@
import type { PageResult, R } from '@/api/types';
import request, { globalHeaders } from '@/api/request';
import { useAppStore } from '@/stores/appStore';
import { appEnv } from '@/utils/env';
import type {
AgentChatRequest,
AgentChatSyncResponse,
AgentItem,
ConversationMessage,
ConversationSummaryItem,
SnailOpenApiUser
} from './types';
export function fetchMyAgents() {
return request<R<AgentItem[] | PageResult<AgentItem>>>({
url: '/snail-ai/agents',
method: 'get'
});
}
export function fetchAgentDetail(id: number) {
return request<R<AgentItem>>({
url: `/snail-ai/agent/${id}`,
method: 'get'
});
}
export function fetchAgentConversations(
id: number,
params: { page?: number; size?: number; start?: string; end?: string }
) {
return request<R<PageResult<ConversationSummaryItem> | ConversationSummaryItem[]>>({
url: `/snail-ai/agent/${id}/conversations`,
method: 'get',
params
});
}
export function fetchConversationMessages(agentId: number, conversationId: string) {
return request<R<ConversationMessage[] | PageResult<ConversationMessage>>>({
url: `/snail-ai/agent/${agentId}/conversation/${conversationId}/messages`,
method: 'get'
});
}
export function createConversation(agentId: number, data: { title?: string }) {
return request<R<ConversationSummaryItem>>({
url: `/snail-ai/agent/${agentId}/conversation`,
method: 'post',
data
});
}
export function deleteConversation(agentId: number, conversationId: string) {
return request<R>({
url: `/snail-ai/agent/${agentId}/conversation/${conversationId}`,
method: 'delete'
});
}
import type { R } from '@/api/types';
import request from '@/api/request';
import type { SnailOpenApiUser } from './types';
export function registerCurrentSnailUser() {
return request<R<SnailOpenApiUser>>({
@@ -64,87 +8,3 @@ export function registerCurrentSnailUser() {
method: 'post'
});
}
export function fetchChatMode() {
return request<R<{ mode?: 'stream' | 'sync' }>>({
url: '/snail-ai/chat/mode',
method: 'get'
});
}
export async function fetchAgentChat(
agentId: number,
data: AgentChatRequest,
options: {
onMessage: (chunk: string) => void;
onThinking?: (chunk: string) => void;
onDone: () => void;
onError: (error: Error) => void;
signal?: AbortSignal;
}
) {
const baseURL = appEnv.baseApi;
const language = useAppStore.getState().appLocale;
try {
const response = await fetch(`${baseURL}/snail-ai/agent/${agentId}/chat/stream`, {
method: 'POST',
headers: {
...globalHeaders(),
'Content-Language': language,
Accept: 'text/event-stream',
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(data),
signal: options.signal
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('ReadableStream not supported');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const eventBlocks = buffer.split(/\r?\n\r?\n/);
buffer = eventBlocks.pop() || '';
for (const block of eventBlocks) {
if (!block.trim()) continue;
let eventName = 'message';
let payload = '';
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) payload += line.slice(5).trim();
}
if (!payload && eventName !== 'done') continue;
if (eventName === 'thinking') {
options.onThinking?.(payload);
} else if (eventName === 'done') {
options.onDone();
return;
} else if (eventName === 'error') {
throw new Error(payload || 'SSE stream error');
} else {
options.onMessage(payload);
}
}
}
options.onDone();
} catch (error) {
if ((error as Error).name !== 'AbortError') options.onError(error as Error);
}
}
export function fetchAgentChatSync(agentId: number, data: AgentChatRequest) {
return request<R<AgentChatSyncResponse>>({
url: `/snail-ai/agent/${agentId}/chat/sync`,
method: 'post',
data
});
}
-55
View File
@@ -1,58 +1,3 @@
export interface AgentItem {
id: number;
name: string;
description?: string;
avatar?: string;
greeting?: string;
status?: number;
presetQuestions?: string[];
}
export interface ConversationSummaryItem {
conversationId: string;
agentId?: number;
title: string;
lastMessageDt?: string;
createDt?: string;
updateDt?: string;
}
export interface ConversationSummaryList {
total: number;
rows: ConversationSummaryItem[];
}
export interface ConversationMessage {
role?: string;
content?: string;
thinking?: string;
message?: string;
text?: string;
messageType?: string;
senderType?: string;
}
export interface AgentChatRequest {
conversationId?: string;
content: string;
disabledMcpServerIds?: number[];
disabledSkillIds?: number[];
}
export interface AgentChatSyncResponse {
conversationId?: string;
content?: string;
traceId?: string;
durationMs?: number;
text?: string;
message?: string;
answer?: string;
reply?: string;
outputText?: string;
result?: string;
messages?: ConversationMessage[];
}
export interface SnailOpenApiUser {
openId: string;
nickname?: string;
+40 -23
View File
@@ -1,5 +1,5 @@
import { history } from '@umijs/max';
import { Modal } from 'antd';
import { message, Modal } from 'antd';
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { useAppStore } from '@/stores/appStore';
import { useTagsViewStore } from '@/stores/tagsViewStore';
@@ -45,6 +45,32 @@ export function globalHeaders() {
};
}
function showRequestError(content: string) {
message.error({ content, key: `request-error:${content}` });
}
function showReloginConfirm() {
if (isRelogin.show) return;
isRelogin.show = true;
Modal.confirm({
title: '系统提示',
content: '登录状态已过期,您可以继续留在该页面,或者重新登录',
okText: '重新登录',
cancelText: '取消',
onOk: () => {
isRelogin.show = false;
removeToken();
useTagsViewStore.getState().resetTags();
history.replace(
`/login?redirect=${encodeURIComponent(`${window.location.pathname || '/'}${window.location.search || ''}`)}`
);
},
onCancel: () => {
isRelogin.show = false;
}
});
}
function stableDataString(data: unknown) {
if (data instanceof FormData) return '';
if (typeof data === 'string') return data;
@@ -188,37 +214,28 @@ service.interceptors.response.use(
const msg = response.data?.msg || '系统未知错误';
if (code === 401) {
if (!isRelogin.show) {
isRelogin.show = true;
Modal.confirm({
title: '系统提示',
content: '登录状态已过期,您可以继续留在该页面,或者重新登录',
okText: '重新登录',
cancelText: '取消',
onOk: () => {
isRelogin.show = false;
removeToken();
useTagsViewStore.getState().resetTags();
history.replace(
`/login?redirect=${encodeURIComponent(`${window.location.pathname || '/'}${window.location.search || ''}`)}`
);
},
onCancel: () => {
isRelogin.show = false;
}
});
}
showReloginConfirm();
return Promise.reject(createHandledError('无效的会话,或者会话已过期,请重新登录。'));
}
if (code === SERVER_ERROR || code === WARN || code !== SUCCESS) {
return Promise.reject(new Error(msg));
showRequestError(msg);
return Promise.reject(createHandledError(msg));
}
return response.data;
},
async error => {
return Promise.reject(new Error(await getErrorMessage(error)));
if (axios.isAxiosError(error) && error.response?.status === 401) {
showReloginConfirm();
return Promise.reject(createHandledError('无效的会话,或者会话已过期,请重新登录。'));
}
const msg = axios.isAxiosError(error)
? await getErrorMessage(error)
: normalizeErrorMessage((error as Error | undefined)?.message) || '系统未知错误';
showRequestError(msg);
return Promise.reject(createHandledError(msg));
}
);
+5 -2
View File
@@ -8,7 +8,8 @@ export interface UserInfo {
userId: string | number;
userName: string;
nickName?: string;
avatar?: string;
avatar?: string | number;
avatarUrl?: string;
deptName?: string;
};
roles: string[];
@@ -26,6 +27,7 @@ export interface UserForm {
gender?: string;
status?: string;
remark?: string;
avatar?: string | number;
postIds?: Array<string | number>;
roleIds?: Array<string | number>;
}
@@ -68,7 +70,8 @@ export interface UserVO extends BaseEntity {
email?: string;
phoneNumber?: string;
gender?: string;
avatar?: string;
avatar?: string | number;
avatarUrl?: string;
status?: string;
deptName?: string;
roles?: RoleVO[];
@@ -83,6 +83,55 @@
overflow: auto;
}
.ant-pro-layout .ant-table-body,
.ant-pro-layout .ant-table-content,
.ant-pro-layout .ant-table-sticky-scroll {
scrollbar-color: rgba(148, 163, 184, 0.42) transparent;
scrollbar-width: thin;
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar,
.ant-pro-layout .ant-table-content::-webkit-scrollbar,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar-button,
.ant-pro-layout .ant-table-content::-webkit-scrollbar-button,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar-track,
.ant-pro-layout .ant-table-content::-webkit-scrollbar-track,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar-track {
background: transparent;
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar-thumb,
.ant-pro-layout .ant-table-content::-webkit-scrollbar-thumb,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.38);
background-clip: padding-box;
border: 2px solid transparent;
border-radius: 999px;
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar-thumb:hover,
.ant-pro-layout .ant-table-content::-webkit-scrollbar-thumb:hover,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 116, 139, 0.56);
}
.ant-pro-layout .ant-table-body::-webkit-scrollbar-corner,
.ant-pro-layout .ant-table-content::-webkit-scrollbar-corner,
.ant-pro-layout .ant-table-sticky-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.icon-select-popover-react {
width: min(450px, calc(100vw - 48px));
}
+53
View File
@@ -40,3 +40,56 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-pro-layout .ant-pro-sider .ant-menu-inline-collapsed .ant-menu-title-content,
.ant-pro-layout .ant-pro-sider.ant-layout-sider-collapsed .ant-menu-title-content {
overflow: hidden;
}
.ant-pro-layout .ant-pro-sider .ant-menu-inline-collapsed .layout-menu-link,
.ant-pro-layout .ant-pro-sider.ant-layout-sider-collapsed .layout-menu-link {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
overflow: hidden;
text-align: center;
}
.ant-pro-layout .ant-pro-sider .ant-menu-inline-collapsed .layout-menu-node,
.ant-pro-layout .ant-pro-sider.ant-layout-sider-collapsed .layout-menu-node {
justify-content: center;
width: 100%;
overflow: hidden;
}
.ant-pro-layout .ant-pro-sider .ant-menu-inline-collapsed .layout-menu-node-icon,
.ant-pro-layout .ant-pro-sider.ant-layout-sider-collapsed .layout-menu-node-icon {
width: 100%;
margin-right: 0;
font-size: 16px;
}
.ant-pro-layout .ant-pro-sider .ant-menu-inline-collapsed .layout-menu-node-title,
.ant-pro-layout .ant-pro-sider.ant-layout-sider-collapsed .layout-menu-node-title {
width: 0;
max-width: 0;
opacity: 0;
}
.ant-pro-layout .ant-pro-sider .ant-layout-sider-children,
.ant-pro-layout .ant-pro-sider .ant-layout-sider-children > div,
.ant-pro-layout .ant-pro-sider .ant-pro-sider-menu,
.ant-pro-layout .ant-pro-sider .ant-menu {
-ms-overflow-style: none;
scrollbar-width: none;
}
.ant-pro-layout .ant-pro-sider .ant-layout-sider-children::-webkit-scrollbar,
.ant-pro-layout .ant-pro-sider .ant-layout-sider-children > div::-webkit-scrollbar,
.ant-pro-layout .ant-pro-sider .ant-pro-sider-menu::-webkit-scrollbar,
.ant-pro-layout .ant-pro-sider .ant-menu::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
+2 -2
View File
@@ -36,13 +36,13 @@
.ant-pro-layout .ant-pro-sider-collapsed-button,
.ant-pro-layout .ant-pro-global-header-collapsed-button {
color: var(--layout-nav-text-react) !important;
background: var(--app-surface-react) !important;
background: var(--layout-collapsed-button-bg-react, var(--app-surface-react)) !important;
}
.ant-pro-layout .ant-pro-sider-collapsed-button:hover,
.ant-pro-layout .ant-pro-global-header-collapsed-button:hover {
color: var(--layout-nav-title-react) !important;
background: var(--layout-nav-action-hover-bg-react) !important;
background: var(--layout-collapsed-button-hover-bg-react, var(--layout-nav-action-hover-bg-react)) !important;
}
.ant-pro-layout .ant-pro-card,
+12
View File
@@ -17,6 +17,18 @@
z-index: 19;
}
.layout-header-static-react .ant-pro-sider,
.layout-header-static-react .ant-layout-sider {
top: 0 !important;
height: 100vh !important;
min-height: 100vh !important;
}
.layout-header-static-react .ant-pro-sider .ant-layout-sider-children,
.layout-header-static-react .ant-layout-sider .ant-layout-sider-children {
height: 100vh !important;
}
.layout-settings-drawer .ant-drawer-body {
padding: 18px;
}
+16 -191
View File
@@ -1,6 +1,5 @@
.ai-chat-page {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
.ai-chat-page {
position: relative;
height: calc(100dvh - 150px);
min-height: 0;
overflow: hidden;
@@ -17,202 +16,28 @@
padding-block-end: 0 !important;
}
.ai-chat-header {
display: flex;
align-items: center;
padding: 0 12px;
background: var(--app-surface-react);
border-bottom: 1px solid var(--app-border-subtle-react);
}
.ai-chat-brand {
display: flex;
gap: 8px;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.ai-chat-brand-dot,
.ai-chat-agent-dot {
width: 12px;
height: 12px;
background: var(--app-link-react);
border-radius: 3px;
}
.ai-chat-body {
display: grid;
grid-template-columns: 292px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.ai-chat-sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 0;
background: var(--app-surface-muted-react);
border-right: 1px solid var(--app-border-subtle-react);
}
.ai-chat-sidebar > .ant-spin-nested-loading {
min-height: 0;
}
.ai-chat-sidebar > .ant-spin-nested-loading > .ant-spin-container {
height: 100%;
min-height: 0;
}
.ai-chat-side-scroll {
height: 100%;
min-height: 0;
overflow: auto;
}
.ai-chat-side-block {
padding: 10px 12px;
}
.ai-chat-block-title {
margin-bottom: 8px;
color: var(--app-text-muted-react);
font-size: 12px;
}
.ai-chat-side-item {
display: flex;
gap: 8px;
align-items: center;
.ai-chat-frame {
display: block;
width: 100%;
height: 34px;
padding: 0 10px;
color: var(--app-text-react);
background: transparent;
height: 100%;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.ai-chat-side-item:hover,
.ai-chat-side-item.active {
color: var(--app-link-react);
background: var(--app-link-soft-react);
}
.ai-chat-side-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-delete {
color: var(--app-danger-react);
opacity: 0;
}
.ai-chat-side-item:hover .ai-chat-delete,
.ai-chat-side-item.active .ai-chat-delete {
opacity: 1;
}
.ai-chat-user {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: var(--app-surface-react);
border-top: 1px solid var(--app-border-subtle-react);
}
.ai-chat-user-name {
font-weight: 600;
}
.ai-chat-user-status {
color: var(--app-text-muted-react);
font-size: 12px;
}
.ai-chat-main {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
.ai-chat-empty {
display: flex;
min-height: 0;
height: 100%;
align-items: center;
justify-content: center;
color: var(--app-text-muted-react);
}
.ai-chat-scroll {
min-height: 0;
padding: 12px 14px 0;
overflow: auto;
}
.ai-chat-content {
width: 100%;
max-width: 920px;
margin: 0 auto;
}
.ai-chat-welcome {
padding: 14px;
margin-bottom: 12px;
background: var(--app-surface-react);
border: 1px solid var(--app-border-subtle-react);
border-radius: 8px;
}
.ai-chat-message-row {
display: flex !important;
justify-content: flex-start;
padding: 4px 0 !important;
border-block-end: 0 !important;
}
.ai-chat-message-row.user {
justify-content: flex-end;
}
.ai-chat-message {
max-width: 80%;
padding: 8px 10px;
line-height: 1.6;
white-space: pre-wrap;
background: var(--app-surface-react);
border: 1px solid var(--app-border-subtle-react);
border-radius: 8px;
}
.ai-chat-message-row.user .ai-chat-message {
color: var(--app-link-strong-react);
background: var(--app-link-soft-react);
border-color: var(--app-link-border-react);
}
.ai-chat-message.pending {
color: var(--app-text-muted-react);
}
.ai-chat-input-wrap {
flex: 0 0 auto;
padding: 8px 14px 10px;
background: var(--app-bg-react);
border-top: 1px solid var(--app-border-subtle-react);
}
.ai-chat-sender {
max-width: 920px;
margin: 0 auto;
background: var(--app-surface-react);
}
.ai-chat-loading {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--app-surface-react) 76%, transparent);
}
+18
View File
@@ -3,6 +3,7 @@
min-height: 100vh;
display: grid;
grid-template-columns: minmax(420px, 1fr) 480px;
overflow-x: hidden;
background: #f4f7fb;
}
@@ -117,6 +118,22 @@
.login-panel-inner {
width: 100%;
min-width: 0;
}
.login-panel .ant-pro-form-login-container,
.register-panel .ant-pro-form-login-container {
width: 100%;
min-width: 0;
padding-inline: 0;
overflow-x: hidden;
}
.login-panel .ant-pro-form-login-main,
.register-panel .ant-pro-form-login-main {
width: 100% !important;
min-width: 0;
max-width: 328px;
}
.login-form-meta {
@@ -179,6 +196,7 @@
grid-template-columns: minmax(420px, 1fr) 480px;
min-height: 100vh;
padding: 40px;
overflow-x: hidden;
background:
radial-gradient(circle at 12% 12%, rgba(22, 119, 255, 0.24), transparent 24%),
radial-gradient(circle at 88% 18%, rgba(20, 184, 166, 0.18), transparent 24%),
+6
View File
@@ -118,12 +118,18 @@
gap: 8px;
}
.captcha-row .ant-form-item {
min-width: 0;
}
.captcha-image {
width: 100%;
height: 40px;
object-fit: cover;
}
.captcha-button {
min-width: 0;
height: 40px;
padding: 0;
overflow: hidden;
+1 -1
View File
@@ -173,7 +173,7 @@ export default function UserSelect({
];
return (
<Modal title={title} open={open} width="80%" onOk={confirm} onCancel={() => onOpenChange(false)} destroyOnHidden>
<Modal title={title} open={open} width="80%" onOk={confirm} onCancel={() => onOpenChange(false)} forceRender>
<div className={`user-select-react${treeCollapsed ? ' is-tree-collapsed' : ''}`}>
<div className="user-select-tree-react">
<div className="user-select-tree-head">
+4 -1
View File
@@ -55,7 +55,7 @@ export default function LayoutSettings({ open, value, onChange, onOpenChange }:
<Drawer
title="布局设置"
open={open}
width={320}
size={320}
onClose={() => onOpenChange(false)}
className="layout-settings-drawer"
>
@@ -133,6 +133,9 @@ export default function LayoutSettings({ open, value, onChange, onOpenChange }:
<SettingRow label="动态标题">
<Switch checked={value.dynamicTitle} onChange={dynamicTitle => patchValue({ dynamicTitle })} />
</SettingRow>
<SettingRow label="全高表格">
<Switch checked={value.fullHeightTable} onChange={fullHeightTable => patchValue({ fullHeightTable })} />
</SettingRow>
<Space style={{ marginTop: 18 }}>
<Button type="primary" icon={<SaveOutlined />} onClick={() => message.success('配置已保存')}>
+2 -1
View File
@@ -130,6 +130,7 @@ export default function MessageBox({ userId }: MessageBoxProps) {
{currentItems.length ? (
<List
dataSource={currentItems}
rowKey={item => String(item.messageId)}
renderItem={item => (
<List.Item className="message-box-item" onClick={() => readMessage(item)}>
<div className="message-box-item-main">
@@ -162,7 +163,7 @@ export default function MessageBox({ userId }: MessageBoxProps) {
trigger="click"
content={content}
arrow={false}
overlayInnerStyle={{ width: 340 }}
styles={{ container: { width: 340 } }}
>
<Tooltip title="消息盒子" open={tooltipOpen && !open} onOpenChange={setTooltipOpen}>
<Badge className="message-box-badge" count={unreadCount} size="small" overflowCount={99} offset={[-5, 5]}>
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '@/stores/appStore';
type TableScrollX = number | string | true;
interface TableScrollOptions {
x: TableScrollX;
bottomOffset?: number;
fallbackY?: number;
disabled?: boolean;
}
function getViewportHeight() {
if (typeof window === 'undefined') return 0;
return window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight;
}
export function useTableScroll(options: TableScrollX | TableScrollOptions) {
const fullHeightTable = useAppStore(state => state.layoutSettings.fullHeightTable);
const normalized = typeof options === 'object' ? options : { x: options };
const { x, bottomOffset = 40, fallbackY = 300, disabled = false } = normalized;
const tableWrapperRef = useRef<HTMLElement>(null);
const frameRef = useRef<number | undefined>(undefined);
const tableBodyRef = useRef<HTMLElement | undefined>(undefined);
const [tableY, setTableY] = useState<number>();
const clearTableBodyHeight = useCallback(() => {
if (!tableBodyRef.current) return;
tableBodyRef.current.style.removeProperty('height');
tableBodyRef.current = undefined;
}, []);
const refreshTableScroll = useCallback(() => {
if (!fullHeightTable || disabled || typeof window === 'undefined') {
clearTableBodyHeight();
setTableY(undefined);
return;
}
if (frameRef.current) {
window.cancelAnimationFrame(frameRef.current);
}
frameRef.current = window.requestAnimationFrame(() => {
const wrapper =
tableWrapperRef.current ||
document.querySelector<HTMLElement>('.ant-pro-page-container') ||
document.body;
const tableBody =
wrapper.querySelector<HTMLElement>('.ant-table-body') ||
wrapper.querySelector<HTMLElement>('.ant-table-content') ||
wrapper;
const pagination = wrapper.querySelector<HTMLElement>('.ant-pagination');
const paginationHeight = pagination ? Math.ceil(pagination.getBoundingClientRect().height) + 16 : 0;
const availableY = Math.floor(
getViewportHeight() - tableBody.getBoundingClientRect().top - paginationHeight - bottomOffset
);
const nextY = availableY > 0 ? availableY : fallbackY;
if (tableBodyRef.current && tableBodyRef.current !== tableBody) {
tableBodyRef.current.style.removeProperty('height');
}
const nextHeight = `${nextY}px`;
if (tableBody.style.height !== nextHeight) {
tableBody.style.height = nextHeight;
}
tableBodyRef.current = tableBody;
setTableY(current => (current === nextY ? current : nextY));
});
}, [bottomOffset, clearTableBodyHeight, disabled, fallbackY, fullHeightTable]);
useEffect(() => {
refreshTableScroll();
if (!fullHeightTable || disabled || typeof window === 'undefined') return undefined;
const wrapper =
tableWrapperRef.current || document.querySelector<HTMLElement>('.ant-pro-page-container') || document.body;
const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(refreshTableScroll);
const mutationObserver =
typeof MutationObserver === 'undefined'
? undefined
: new MutationObserver(() => {
refreshTableScroll();
});
resizeObserver?.observe(wrapper);
mutationObserver?.observe(wrapper, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['class', 'style']
});
window.addEventListener('resize', refreshTableScroll);
window.visualViewport?.addEventListener('resize', refreshTableScroll);
return () => {
if (frameRef.current) {
window.cancelAnimationFrame(frameRef.current);
}
clearTableBodyHeight();
resizeObserver?.disconnect();
mutationObserver?.disconnect();
window.removeEventListener('resize', refreshTableScroll);
window.visualViewport?.removeEventListener('resize', refreshTableScroll);
};
}, [clearTableBodyHeight, disabled, fullHeightTable, refreshTableScroll]);
const tableScroll = useMemo(() => {
if (!fullHeightTable || disabled || !tableY) return { x };
return {
x,
y: tableY
};
}, [disabled, fullHeightTable, tableY, x]);
return {
tableScroll,
tableWrapperRef,
refreshTableScroll
};
}
+7 -4
View File
@@ -27,7 +27,7 @@ import { useTagsViewStore } from '@/stores/tagsViewStore';
import { useUserStore } from '@/stores/userStore';
import { getToken, removeToken } from '@/utils/auth';
import { appEnv } from '@/utils/env';
import { appendRouteQuery, flattenBackendRoutes } from '@/utils/menu';
import { appendRouteQuery, flattenBackendRoutes, routeIcon } from '@/utils/menu';
import { closePush, initMessageBox, initPush } from '@/utils/push';
import { isHttp } from '@/utils/ruoyi';
@@ -181,7 +181,8 @@ export default function BasicLayout() {
const proLayoutClassName =
[
layoutSettings.fixedHeader ? undefined : 'layout-header-static-react',
effectiveNavDark ? 'layout-nav-dark-react' : 'layout-nav-light-react'
effectiveNavDark ? 'layout-nav-dark-react' : 'layout-nav-light-react',
layoutSettings.fullHeightTable ? 'layout-full-height-table-react' : undefined
]
.filter(Boolean)
.join(' ') || undefined;
@@ -196,6 +197,8 @@ export default function BasicLayout() {
'--layout-nav-menu-hover-bg-react': navColors.menuHoverBg,
'--layout-nav-menu-selected-bg-react': navColors.menuSelectedBg,
'--layout-nav-badge-shadow-react': navColors.badgeShadow,
'--layout-collapsed-button-bg-react': effectiveNavDark ? navColors.bg : appColors.surface,
'--layout-collapsed-button-hover-bg-react': effectiveNavDark ? '#1f2937' : navColors.actionHoverBg,
backgroundColor: appColors.bg
} as CSSProperties;
const proLayoutContentStyle = {
@@ -431,7 +434,7 @@ export default function BasicLayout() {
menuHeaderRender={menuHeaderRender}
headerTitleRender={headerTitleRender}
location={{ pathname: location.pathname }}
route={{ path: '/', routes: [{ path: '/index', name: '首页' }, ...menuData] }}
route={{ path: '/', routes: [{ path: '/index', name: '首页', icon: routeIcon('dashboard') }, ...menuData] }}
menuItemRender={(item, _dom) => (
<button
type="button"
@@ -451,7 +454,7 @@ export default function BasicLayout() {
)}
subMenuItemRender={(item, _dom) => menuNode(item as RuntimeMenuItem)}
avatarProps={{
src: userInfo?.user.avatar || defaultAvatar,
src: userInfo?.user.avatarUrl || defaultAvatar,
title: userInfo?.user.nickName || userInfo?.user.userName,
render: (_, dom) => (
<Dropdown menu={{ items: userMenu }} placement="bottomRight" trigger={['click']}>
+46 -507
View File
@@ -1,533 +1,72 @@
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { ReloadOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { Bubble, Conversations, Prompts, Sender, type BubbleItemType } from '@ant-design/x';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Avatar, Button, Empty, message, Space, Spin, Typography } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
AgentChatSyncResponse,
AgentItem,
ConversationMessage,
ConversationSummaryItem
} from '@/api/ai/agent/types';
import {
createConversation,
deleteConversation,
fetchAgentChat,
fetchAgentChatSync,
fetchAgentConversations,
fetchChatMode,
fetchConversationMessages,
fetchMyAgents,
registerCurrentSnailUser
} from '@/api/ai/agent';
import { Button, Empty, Spin } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { registerCurrentSnailUser } from '@/api/ai/agent';
import { getToken } from '@/utils/auth';
import { appEnv } from '@/utils/env';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
const aiChatKeys = {
bootstrap: ['ai-chat', 'bootstrap'] as const,
conversations: (agentId?: number) => ['ai-chat', 'conversations', agentId] as const,
messages: (agentId?: number, conversationId?: string) => ['ai-chat', 'messages', agentId, conversationId] as const
};
function getList<T>(payload: unknown): T[] {
const container = (payload as { data?: unknown })?.data ?? payload;
if (Array.isArray(container)) return container as T[];
const record = container as { rows?: T[]; list?: T[]; records?: T[] };
if (Array.isArray(record?.rows)) return record.rows;
if (Array.isArray(record?.list)) return record.list;
if (Array.isArray(record?.records)) return record.records;
return [];
}
function normalizeAgentList(payload: unknown): AgentItem[] {
return getList<Record<string, unknown>>(payload)
.map(item => ({
id: Number(item.id ?? item.agentId),
name: String(item.name ?? item.title ?? ''),
description: item.description as string | undefined,
avatar: item.avatar as string | undefined,
greeting: item.greeting as string | undefined,
status: item.status as number | undefined,
presetQuestions: Array.isArray(item.presetQuestions) ? item.presetQuestions.map(String) : []
}))
.filter(item => Number.isFinite(item.id) && !!item.name);
}
function normalizeConversationList(payload: unknown): ConversationSummaryItem[] {
return getList<Record<string, unknown>>(payload)
.map(item => ({
conversationId: String(item.conversationId ?? item.id ?? ''),
title: String(item.title ?? item.name ?? ''),
lastMessageDt: item.lastMessageDt as string | undefined,
createDt: (item.createDt ?? item.createTime) as string | undefined,
updateDt: (item.updateDt ?? item.updateTime) as string | undefined
}))
.filter(item => !!item.conversationId)
.sort(
(a, b) =>
new Date(b.lastMessageDt || b.createDt || 0).getTime() - new Date(a.lastMessageDt || a.createDt || 0).getTime()
);
}
function normalizeMessageList(payload: unknown): ChatMessage[] {
return getList<ConversationMessage>(payload)
.map(item => {
const role = String(item.role || item.messageType || item.senderType || '').toLowerCase();
return {
role: role === 'user' ? 'user' : 'assistant',
content: String(item.content ?? item.message ?? item.text ?? '')
} as ChatMessage;
})
.filter(item => !!item.content);
}
function normalizeStreamChunk(chunk: string) {
const text = String(chunk || '');
if (!text.trim()) return '';
const tryParse = (raw: string) => {
try {
const obj = JSON.parse(raw) as { content?: string };
return typeof obj.content === 'string' ? obj.content : null;
} catch {
return null;
}
};
const single = tryParse(text);
if (single !== null) return single;
const lines = text
.replace(/}\s*{/g, '}\n{')
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (!lines.length) return text;
let merged = '';
for (const line of lines) {
const parsed = tryParse(line);
if (parsed === null) return text;
merged += parsed;
}
return merged;
}
function extractSyncReply(payload: AgentChatSyncResponse) {
const candidates = [
payload.content,
payload.text,
payload.message,
payload.answer,
payload.reply,
payload.outputText,
payload.result
];
const hit = candidates.find(item => typeof item === 'string' && item.trim());
if (hit) return normalizeStreamChunk(String(hit)) || String(hit);
if (Array.isArray(payload.messages)) {
return (
normalizeMessageList(payload.messages)
.filter(item => item.role === 'assistant')
.at(-1)?.content || ''
);
}
return '';
function buildChatUrl(openId: string, trustedCredential: string) {
const params = new URLSearchParams({ openId, trustedCredential });
return `${appEnv.baseApi}/snail-chat/?${params.toString()}`;
}
export default function AiChatPage() {
const queryClient = useQueryClient();
const [currentAgentId, setCurrentAgentId] = useState<number>();
const [currentConversationId, setCurrentConversationId] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [chatUrl, setChatUrl] = useState('');
const [loadError, setLoadError] = useState('');
const [loading, setLoading] = useState(false);
const bootstrapQuery = useQuery({
queryKey: aiChatKeys.bootstrap,
queryFn: async () => {
const [userRes, agentsRes, modeRes] = await Promise.all([
registerCurrentSnailUser(),
fetchMyAgents(),
fetchChatMode()
]);
return {
nickname: userRes.data?.nickname || '',
agents: normalizeAgentList(agentsRes.data),
sendMode: modeRes.data?.mode === 'sync' ? ('sync' as const) : ('stream' as const)
};
const loadChat = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const token = getToken();
if (!token) {
setChatUrl('');
setLoadError('登录凭证不存在,请重新登录后再试');
return;
}
});
const agents = bootstrapQuery.data?.agents || [];
const currentAgent = useMemo(
() => agents.find(agent => agent.id === currentAgentId) || agents[0] || null,
[agents, currentAgentId]
);
const sendMode = bootstrapQuery.data?.sendMode || 'stream';
const nickname = bootstrapQuery.data?.nickname || '';
const conversationsQuery = useQuery({
queryKey: aiChatKeys.conversations(currentAgent?.id),
enabled: !!currentAgent?.id,
queryFn: async () => {
if (!currentAgent?.id) return [];
const res = await fetchAgentConversations(currentAgent.id, { page: 1, size: 50 });
return normalizeConversationList(res.data);
const res = await registerCurrentSnailUser();
if (!res.data?.openId) {
setChatUrl('');
setLoadError('获取 AI 用户身份失败');
return;
}
});
const conversationMessagesQuery = useQuery({
queryKey: aiChatKeys.messages(currentAgent?.id, currentConversationId),
enabled: !!currentAgent?.id && !!currentConversationId,
queryFn: async () => {
if (!currentAgent?.id) return [];
const res = await fetchConversationMessages(currentAgent.id, currentConversationId);
return normalizeMessageList(res.data);
}
});
const presetQuestions = useMemo(() => (currentAgent?.presetQuestions || []).filter(Boolean), [currentAgent]);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
setChatUrl(buildChatUrl(res.data.openId, token));
} catch {
setChatUrl('');
setLoadError('加载 AI 聊天失败,请稍后重试');
} finally {
setLoading(false);
}
}, []);
const finishSending = useCallback(() => {
setSending(false);
clearTimer();
abortRef.current = null;
}, [clearTimer]);
useEffect(() => {
if (!currentAgentId && agents[0]) {
setCurrentAgentId(agents[0].id);
}
}, [agents, currentAgentId]);
useEffect(() => {
if (currentConversationId && conversationMessagesQuery.data && !sending) {
setMessages(conversationMessagesQuery.data);
}
}, [conversationMessagesQuery.data, currentConversationId, sending]);
useEffect(() => {
return () => {
abortRef.current?.abort();
clearTimer();
};
}, [clearTimer]);
const refreshConversationData = useCallback(
async (agentId: number, conversationId?: string) => {
await queryClient.invalidateQueries({ queryKey: aiChatKeys.conversations(agentId) });
if (conversationId) {
await queryClient.invalidateQueries({ queryKey: aiChatKeys.messages(agentId, conversationId) });
}
},
[queryClient]
);
const selectAgent = (agentId: string | number) => {
const nextAgentId = Number(agentId);
if (currentAgentId === nextAgentId) return;
abortRef.current?.abort();
finishSending();
setCurrentAgentId(nextAgentId);
setCurrentConversationId('');
setMessages([]);
};
const startNewConversation = () => {
abortRef.current?.abort();
finishSending();
setCurrentConversationId('');
setMessages([]);
};
const selectConversation = (conversationId: string | number) => {
const nextConversationId = String(conversationId);
if (currentConversationId === nextConversationId) return;
abortRef.current?.abort();
finishSending();
setCurrentConversationId(nextConversationId);
setMessages([]);
};
const removeConversation = async (conversationId: string) => {
if (!currentAgent) return;
await deleteConversation(currentAgent.id, conversationId);
message.success('删除成功');
if (currentConversationId === conversationId) {
abortRef.current?.abort();
finishSending();
setCurrentConversationId('');
setMessages([]);
}
await refreshConversationData(currentAgent.id);
};
const sendMessage = async (value?: string) => {
const text = (value ?? content).trim();
if (!currentAgent || !text || sending) return;
setContent('');
setSending(true);
clearTimer();
timerRef.current = setTimeout(() => {
abortRef.current?.abort();
finishSending();
message.warning('响应超时,已恢复发送按钮,请重试');
}, 300000);
let targetConversationId = currentConversationId;
if (!targetConversationId) {
try {
const res = await createConversation(currentAgent.id, { title: text.slice(0, 20) });
if (!res.data?.conversationId) {
finishSending();
message.error('创建会话失败,请稍后重试');
return;
}
targetConversationId = res.data.conversationId;
setCurrentConversationId(targetConversationId);
await refreshConversationData(currentAgent.id);
} catch (error) {
finishSending();
message.error((error as Error).message || '创建会话失败,请稍后重试');
return;
}
}
setMessages(items => [...items, { role: 'user', content: text }]);
if (sendMode === 'sync') {
try {
const res = await fetchAgentChatSync(currentAgent.id, { conversationId: targetConversationId, content: text });
setMessages(items => [
...items,
{ role: 'assistant', content: extractSyncReply(res.data) || '(后端已返回空消息)' }
]);
await refreshConversationData(currentAgent.id, targetConversationId);
} catch (error) {
message.error((error as Error).message || '对话失败,请稍后重试');
} finally {
finishSending();
}
return;
}
setMessages(items => [...items, { role: 'assistant', content: '' }]);
abortRef.current?.abort();
abortRef.current = new AbortController();
await fetchAgentChat(
currentAgent.id,
{ conversationId: targetConversationId, content: text },
{
signal: abortRef.current.signal,
onMessage(chunk) {
setMessages(items => {
const next = [...items];
const index = next.length - 1;
next[index] = { ...next[index], content: `${next[index]?.content || ''}${normalizeStreamChunk(chunk)}` };
return next;
});
},
async onDone() {
setMessages(items => {
const next = [...items];
const index = next.length - 1;
if (next[index]?.role === 'assistant' && !next[index].content.trim()) {
next[index] = { ...next[index], content: '(后端已返回空消息)' };
}
return next;
});
finishSending();
await refreshConversationData(currentAgent.id, targetConversationId);
},
onError(error) {
setMessages(items => items.filter((_, index) => index !== items.length - 1));
finishSending();
message.error(error.message || '对话失败,请稍后重试');
}
}
);
};
const agentItems = useMemo(
() =>
agents.map(agent => ({
key: String(agent.id),
label: agent.name,
icon: (
<Avatar size={20} src={agent.avatar}>
{agent.name.slice(0, 1)}
</Avatar>
)
})),
[agents]
);
const conversationItems = useMemo(
() =>
(conversationsQuery.data || []).map(conv => ({
key: conv.conversationId,
label: conv.title || '未命名会话'
})),
[conversationsQuery.data]
);
const bubbleItems = useMemo<BubbleItemType[]>(
() =>
messages.map((item, index) => ({
key: `${item.role}-${index}`,
role: item.role === 'user' ? 'user' : 'ai',
content: item.content || '正在生成...',
loading: item.role === 'assistant' && !item.content
})),
[messages]
);
loadChat();
}, [loadChat]);
return (
<PageContainer className="ai-chat-container" title={false}>
<div className="ai-chat-page">
<header className="ai-chat-header">
<div className="ai-chat-brand">
<span className="ai-chat-brand-dot" />
Snail AI
{loading ? (
<div className="ai-chat-loading">
<Spin />
</div>
</header>
<div className="ai-chat-body">
<aside className="ai-chat-sidebar">
<div className="ai-chat-side-block">
<Button
type="primary"
block
icon={<PlusOutlined />}
disabled={!agents.length}
onClick={startNewConversation}
>
新对话
</Button>
</div>
<Spin spinning={bootstrapQuery.isLoading || conversationsQuery.isLoading}>
<div className="ai-chat-side-scroll">
<div className="ai-chat-side-block">
<div className="ai-chat-block-title">我的智能体</div>
{!agents.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无智能体" /> : null}
<Conversations
className="ai-chat-conversations"
items={agentItems}
activeKey={currentAgent ? String(currentAgent.id) : undefined}
onActiveChange={selectAgent}
/>
</div>
<div className="ai-chat-side-block">
<div className="ai-chat-block-title">对话记录</div>
{!conversationItems.length ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无会话" />
) : null}
<Conversations
className="ai-chat-conversations"
items={conversationItems}
activeKey={currentConversationId}
onActiveChange={selectConversation}
menu={item => ({
items: [{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true }],
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
removeConversation(item.key);
}
})}
/>
</div>
</div>
</Spin>
<div className="ai-chat-user">
<Avatar>{(nickname || 'U').slice(0, 1)}</Avatar>
<div>
<div className="ai-chat-user-name">{nickname || '已登录用户'}</div>
<div className="ai-chat-user-status">已登录</div>
</div>
</div>
</aside>
<main className="ai-chat-main">
{!currentAgent ? (
<div className="ai-chat-empty">请先选择一个智能体开始对话</div>
{chatUrl ? (
<iframe className="ai-chat-frame" src={chatUrl} title="Snail AI" allow="clipboard-read; clipboard-write" />
) : (
<>
<div className="ai-chat-scroll">
<div className="ai-chat-content">
{!currentConversationId && !messages.length && (
<div className="ai-chat-welcome">
<Space align="start">
<Avatar size={40} src={currentAgent.avatar}>
{currentAgent.name.slice(0, 1)}
</Avatar>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{currentAgent.name}
</Typography.Title>
<Typography.Text type="secondary">{currentAgent.description || '暂无描述'}</Typography.Text>
</div>
</Space>
<p>{currentAgent.greeting || '你好,我是你的智能助手。'}</p>
{presetQuestions.length > 0 && (
<Prompts
wrap
items={presetQuestions.map(question => ({
key: question,
label: question,
disabled: sending
}))}
onItemClick={({ data }) => sendMessage(String(data.key))}
/>
<Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}>
{loadError ? (
<Button type="primary" icon={<ReloadOutlined />} onClick={loadChat}>
重新加载
</Button>
) : null}
</Empty>
)}
</div>
)}
<Spin
spinning={conversationMessagesQuery.isFetching && !!currentConversationId && !messages.length}
>
<Bubble.List
className="ai-chat-bubble-list"
autoScroll
items={bubbleItems}
role={{
user: { placement: 'end', variant: 'filled' },
ai: {
placement: 'start',
variant: 'outlined',
avatar: <Avatar src={currentAgent.avatar}>{currentAgent.name.slice(0, 1)}</Avatar>
}
}}
/>
</Spin>
</div>
</div>
<div className="ai-chat-input-wrap">
<Sender
className="ai-chat-sender"
value={content}
loading={sending}
disabled={sending}
placeholder="给智能体发消息"
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={setContent}
onSubmit={sendMessage}
onCancel={() => {
abortRef.current?.abort();
finishSending();
}}
/>
</div>
</>
)}
</main>
</div>
</div>
</PageContainer>
);
}
+3 -1
View File
@@ -16,6 +16,7 @@ import { addDemo, delDemo, getDemo, listDemo, updateDemo } from '@/api/demo/demo
import RowActions from '@/components/common/RowActions';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { hasPermi } from '@/utils/permission';
import { toPageQuery, toTableData } from '@/utils/ruoyi';
@@ -24,6 +25,7 @@ const defaultDemoForm: DemoForm = {};
export default function DemoDemoPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(760);
const [form] = Form.useForm<DemoForm>();
const userInfo = useUserStore(state => state.userInfo);
const { ids, selectedOne, handleSelectionChange, clearSelection } = useTableSelection<DemoVO>(row => row.id);
@@ -103,7 +105,7 @@ export default function DemoDemoPage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 760 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
+3 -1
View File
@@ -15,6 +15,7 @@ import type { TreeForm, TreeQuery, TreeVO } from '@/api/demo/tree/types';
import { addTree, delTree, getTree, listTree, updateTree } from '@/api/demo/tree';
import RowActions from '@/components/common/RowActions';
import { useTreeTableExpand } from '@/hooks/useTreeTableExpand';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { hasPermi } from '@/utils/permission';
import { handleTree } from '@/utils/ruoyi';
@@ -37,6 +38,7 @@ function toTreeSelectData(nodes: TreeVO[]): TreeSelectNode[] {
export default function DemoTreePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(760);
const [form] = Form.useForm<TreeForm>();
const userInfo = useUserStore(state => state.userInfo);
const [treeOptions, setTreeOptions] = useState<TreeVO[]>([]);
@@ -125,7 +127,7 @@ export default function DemoTreePage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 760 }}
scroll={tableScroll}
pagination={false}
search={{ labelWidth: 90 }}
expandable={{
+3 -1
View File
@@ -10,6 +10,7 @@ import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -18,6 +19,7 @@ import { toPageQuery, toTableData, withTableSort } from '@/utils/ruoyi';
export default function MonitorLoginInfoPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1352);
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_device_type', 'sys_common_status');
const { ids, selectedRows, selectedOne, handleSelectionChange, clearSelection } = useTableSelection<LoginInfoVO>(
@@ -130,7 +132,7 @@ export default function MonitorLoginInfoPage() {
actionRef={actionRef}
rowKey="infoId"
columns={columns}
scroll={{ x: 1352 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async (params, sort) => {
+3 -1
View File
@@ -8,6 +8,7 @@ import DictTag from '@/components/common/DictTag';
import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -16,6 +17,7 @@ import { toPageQuery, toTableData } from '@/utils/ruoyi';
export default function MonitorOnlinePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1320);
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_device_type');
const canForceLogout = hasPermi(userInfo, ['monitor:online:forceLogout']);
@@ -119,7 +121,7 @@ export default function MonitorOnlinePage() {
actionRef={actionRef}
rowKey="tokenId"
columns={columns}
scroll={{ x: 1320 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
request={async params => {
const res = await listOnline(toPageQuery(params));
+4 -2
View File
@@ -14,6 +14,7 @@ import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -27,6 +28,7 @@ function dictText(dicts: DictData[] | undefined, value?: string | number) {
export default function MonitorOperlogPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1680);
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_oper_type', 'sys_common_status', 'sys_device_type');
const { ids, handleSelectionChange, clearSelection } = useTableSelection<OperLogVO>(row => row.operId);
@@ -163,7 +165,7 @@ export default function MonitorOperlogPage() {
rowKey="operId"
columns={columns}
search={{ labelWidth: 90 }}
scroll={{ x: 1680 }}
scroll={tableScroll}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async (params, sort) => {
const { operTimeRange, ...tableParams } = params;
@@ -214,7 +216,7 @@ export default function MonitorOperlogPage() {
<Descriptions
column={1}
bordered
labelStyle={{ width: 110 }}
styles={{ label: { width: 110 } }}
items={[
{
key: 'status',
+3 -1
View File
@@ -22,6 +22,7 @@ import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmAction } from '@/utils/modal';
@@ -42,6 +43,7 @@ function getRuleList(ruleList?: string[], ruleValue?: string) {
export default function SystemClientPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1580);
const [form] = Form.useForm<ClientForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable', 'sys_grant_type', 'sys_device_type');
@@ -210,7 +212,7 @@ export default function SystemClientPage() {
rowKey="id"
columns={columns}
search={{ labelWidth: 95 }}
scroll={{ x: 1580 }}
scroll={tableScroll}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async params => {
const query = toPageQuery(params);
+5 -3
View File
@@ -25,6 +25,7 @@ import {
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmTitleSafe } from '@/utils/modal';
@@ -40,6 +41,7 @@ const configTypeTabs = [
export default function SystemConfigPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(900);
const [form] = Form.useForm<ConfigForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_yes_no');
@@ -148,7 +150,7 @@ export default function SystemConfigPage() {
actionRef={actionRef}
rowKey="configId"
columns={columns}
scroll={{ x: 900 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
request={async params => {
const query = {
@@ -164,7 +166,7 @@ export default function SystemConfigPage() {
<div className="system-config-page">
<Tabs
activeKey={activeConfigType}
tabPosition="left"
tabPlacement="start"
className="system-config-page__tabs"
items={configTypeTabs}
onChange={changeConfigType}
@@ -209,7 +211,7 @@ export default function SystemConfigPage() {
form={form}
layout="vertical"
initialValues={{ configType: 'Y' }}
modalProps={{ destroyOnHidden: true, onCancel: closeModal }}
modalProps={{ forceRender: true, onCancel: closeModal }}
onOpenChange={open => !open && closeModal()}
onFinish={submit}
>
+3 -1
View File
@@ -22,6 +22,7 @@ import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTreeTableExpand } from '@/hooks/useTreeTableExpand';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -49,6 +50,7 @@ function toTreeSelectData(depts: DeptVO[]): TreeSelectNode[] {
export default function SystemDeptPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(850);
const [form] = Form.useForm<DeptForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable');
@@ -191,7 +193,7 @@ export default function SystemDeptPage() {
actionRef={actionRef}
rowKey="deptId"
columns={columns}
scroll={{ x: 850 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={false}
expandable={{
+7 -4
View File
@@ -20,6 +20,7 @@ import { addType, delType, getType, listType, refreshCache, updateType } from '@
import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useDictStore } from '@/stores/dictStore';
import { useUserStore } from '@/stores/userStore';
@@ -53,6 +54,8 @@ function tagColor(listClass?: string) {
export default function SystemDictPage() {
const typeActionRef = useRef<ActionType | undefined>(undefined);
const dataActionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll: typeTableScroll, tableWrapperRef: typeTableWrapperRef } = useTableScroll(702);
const { tableScroll: dataTableScroll, tableWrapperRef: dataTableWrapperRef } = useTableScroll(816);
const [typeForm] = Form.useForm<DictTypeForm>();
const [dataForm] = Form.useForm<DictDataForm>();
const userInfo = useUserStore(state => state.userInfo);
@@ -264,12 +267,12 @@ export default function SystemDictPage() {
return (
<PageContainer title="字典管理">
<div className="dict-grid">
<section className="dict-panel">
<section ref={typeTableWrapperRef} className="dict-panel">
<ProTable<DictTypeVO, DictTypeQuery>
actionRef={typeActionRef}
rowKey="dictId"
columns={typeColumns}
scroll={{ x: 702 }}
scroll={typeTableScroll}
search={{ labelWidth: 90 }}
rowClassName={row => (row.dictId === currentDict?.dictId ? 'dict-row-current' : '')}
rowSelection={{ selectedRowKeys: typeIds, onChange: handleTypeSelectionChange }}
@@ -334,12 +337,12 @@ export default function SystemDictPage() {
/>
</section>
<section className="dict-panel">
<section ref={dataTableWrapperRef} className="dict-panel">
<ProTable<DictData, DictDataQuery>
actionRef={dataActionRef}
rowKey="dictCode"
columns={dataColumns}
scroll={{ x: 816 }}
scroll={dataTableScroll}
search={{ labelWidth: 90 }}
params={{ dictType: currentDict?.dictType }}
rowSelection={{ selectedRowKeys: dataIds, onChange: handleDataSelectionChange }}
+3 -1
View File
@@ -22,6 +22,7 @@ import IconSelect from '@/components/common/IconSelect';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useLoading } from '@/hooks/useLoading';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { routeIcon } from '@/utils/menu';
import { dictOptions } from '@/utils/dict';
@@ -56,6 +57,7 @@ function toTreeNodes(nodes: MenuTreeOption[]): DataNode[] {
export default function SystemMenuPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1120);
const [form] = Form.useForm<MenuForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_show_hide', 'sys_normal_disable', 'sys_yes_no');
@@ -200,7 +202,7 @@ export default function SystemMenuPage() {
actionRef={actionRef}
rowKey="menuId"
columns={columns}
scroll={{ x: 1120 }}
scroll={tableScroll}
pagination={false}
search={{ labelWidth: 90 }}
expandable={{ defaultExpandAllRows: false }}
+21 -11
View File
@@ -22,6 +22,7 @@ import RichTextEditor from '@/components/common/RichTextEditor';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { resolveOssContent } from '@/utils/ossContent';
import { dictOptions } from '@/utils/dict';
@@ -47,6 +48,7 @@ function SafeHtmlContent({ html }: { html: string }) {
export default function SystemNoticePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1120);
const [form] = Form.useForm<NoticeForm>();
const location = useLocation();
const userInfo = useUserStore(state => state.userInfo);
@@ -184,7 +186,7 @@ export default function SystemNoticePage() {
actionRef={actionRef}
rowKey="noticeId"
columns={columns}
scroll={{ x: 1120 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async params => {
@@ -245,16 +247,24 @@ export default function SystemNoticePage() {
<Modal title="公告详情" open={detailOpen} width={820} footer={null} onCancel={closeDetail}>
<div className="notice-detail">
<h2>{detail?.noticeTitle || '-'}</h2>
<Descriptions column={2} size="small">
<Descriptions.Item label="类型">
<DictTag options={dicts.sys_notice_type} value={detail?.noticeType} />
</Descriptions.Item>
<Descriptions.Item label="状态">
<DictTag options={dicts.sys_notice_status} value={detail?.status} />
</Descriptions.Item>
<Descriptions.Item label="创建者">{detail?.createByName || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{detail?.createTime || '-'}</Descriptions.Item>
</Descriptions>
<Descriptions
column={2}
size="small"
items={[
{
key: 'noticeType',
label: '类型',
children: <DictTag options={dicts.sys_notice_type} value={detail?.noticeType} />
},
{
key: 'status',
label: '状态',
children: <DictTag options={dicts.sys_notice_status} value={detail?.status} />
},
{ key: 'createByName', label: '创建者', children: detail?.createByName || '-' },
{ key: 'createTime', label: '创建时间', children: detail?.createTime || '-' }
]}
/>
<SafeHtmlContent html={safeNoticeContent} />
</div>
</Modal>
+3 -1
View File
@@ -25,6 +25,7 @@ import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmAction } from '@/utils/modal';
@@ -46,6 +47,7 @@ function accessPolicyTag(value?: string) {
export default function SystemOssConfigPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1460);
const [form] = Form.useForm<OssConfigForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_yes_no');
@@ -177,7 +179,7 @@ export default function SystemOssConfigPage() {
actionRef={actionRef}
rowKey="ossConfigId"
columns={columns}
scroll={{ x: 1460 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async params => {
+3 -1
View File
@@ -20,6 +20,7 @@ import ImagePreview from '@/components/common/ImagePreview';
import ImageUpload from '@/components/common/ImageUpload';
import RowActions from '@/components/common/RowActions';
import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { saveValidatedBlob } from '@/utils/download';
import { hasPermi } from '@/utils/permission';
@@ -31,6 +32,7 @@ function isImage(fileSuffix?: string) {
export default function SystemOssPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1300);
const userInfo = useUserStore(state => state.userInfo);
const [selectedRows, setSelectedRows] = useState<OssVO[]>([]);
const [previewListResource, setPreviewListResource] = useState(true);
@@ -146,7 +148,7 @@ export default function SystemOssPage() {
actionRef={actionRef}
rowKey="ossId"
columns={columns}
scroll={{ x: 1300 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: ids, onChange: (_, rows) => setSelectedRows(rows) }}
request={async (params, sort) => {
+3 -1
View File
@@ -23,6 +23,7 @@ import TreePanel from '@/components/common/TreePanel';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -47,6 +48,7 @@ function toTreeSelectData(nodes: DeptTreeVO[]): TreeSelectNode[] {
export default function SystemPostPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(960);
const [form] = Form.useForm<PostForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable');
@@ -182,7 +184,7 @@ export default function SystemPostPage() {
actionRef={actionRef}
rowKey="postId"
columns={columns}
scroll={{ x: 960 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: selectedIds, onChange: handleSelectionChange }}
request={async params => {
+3 -1
View File
@@ -6,6 +6,7 @@ import { Button, message, Popconfirm, Tag } from 'antd';
import { useRef, useState } from 'react';
import type { UserQuery, UserVO } from '@/api/system/user/types';
import { allocatedUserList, authUserCancel, authUserCancelAll } from '@/api/system/role';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { hasPermi } from '@/utils/permission';
import { toPageQuery, toTableData } from '@/utils/ruoyi';
@@ -19,6 +20,7 @@ export default function RoleAuthUserPage() {
const location = useLocation();
const roleId = getRoleId(location.pathname);
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1210);
const userInfo = useUserStore(state => state.userInfo);
const [selectedRows, setSelectedRows] = useState<UserVO[]>([]);
const [selectOpen, { setTrue: openSelectModal, setFalse: closeSelectModal }] = useBoolean(false);
@@ -76,7 +78,7 @@ export default function RoleAuthUserPage() {
actionRef={actionRef}
rowKey="userId"
columns={columns}
scroll={{ x: 1210 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{
+3 -1
View File
@@ -29,6 +29,7 @@ import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmAction } from '@/utils/modal';
@@ -48,6 +49,7 @@ const defaultRoleForm: RoleForm = {
export default function SystemRolePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1132);
const [form] = Form.useForm<RoleForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable');
@@ -233,7 +235,7 @@ export default function SystemRolePage() {
actionRef={actionRef}
rowKey="roleId"
columns={columns}
scroll={{ x: 1132 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{
+12 -5
View File
@@ -5,6 +5,7 @@ import { type MouseEvent, useEffect, useMemo, useState } from 'react';
import type { RoleVO } from '@/api/system/role/types';
import type { UserVO } from '@/api/system/user/types';
import { getAuthRole, updateAuthRole } from '@/api/system/user';
import { useTableScroll } from '@/hooks/useTableScroll';
function getUserId(pathname: string) {
return pathname.split('/').filter(Boolean).at(-1) || '';
}
@@ -19,6 +20,7 @@ function isSelectionControl(target: EventTarget | null) {
export default function UserAuthRolePage() {
const location = useLocation();
const userId = getUserId(location.pathname);
const { tableScroll } = useTableScroll(1010);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<UserVO>();
const [roles, setRoles] = useState<RoleVO[]>([]);
@@ -71,15 +73,20 @@ export default function UserAuthRolePage() {
return (
<PageContainer title="分配角色">
<Descriptions bordered column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="用户昵称">{user?.nickName}</Descriptions.Item>
<Descriptions.Item label="登录账号">{user?.userName}</Descriptions.Item>
</Descriptions>
<Descriptions
bordered
column={2}
style={{ marginBottom: 16 }}
items={[
{ key: 'nickName', label: '用户昵称', children: user?.nickName },
{ key: 'userName', label: '登录账号', children: user?.userName }
]}
/>
<ProTable<RoleVO>
loading={loading}
rowKey="roleId"
columns={columns}
scroll={{ x: 1010 }}
scroll={tableScroll}
dataSource={roles}
search={false}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
@@ -40,7 +40,7 @@ export default function UserDetailDrawer({ open, userId, genderOptions, onClose
};
return (
<Drawer title="用户信息详情" open={open} width={680} onClose={closeDrawer} destroyOnHidden>
<Drawer title="用户信息详情" open={open} size={680} onClose={closeDrawer} destroyOnHidden>
<ProDescriptions
loading={loading}
column={2}
+3 -1
View File
@@ -36,6 +36,7 @@ import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmAction } from '@/utils/modal';
@@ -70,6 +71,7 @@ function toTreeSelectData(depts: DeptTreeVO[]): TreeSelectNode[] {
export default function SystemUserPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1220);
const [form] = Form.useForm<UserForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable', 'sys_user_gender');
@@ -352,7 +354,7 @@ export default function SystemUserPage() {
actionRef={actionRef}
rowKey="userId"
columns={columns}
scroll={{ x: 1220 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{
+8 -4
View File
@@ -19,8 +19,8 @@ import {
Upload,
type UploadProps
} from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Cropper, { type Area, type Point } from 'react-easy-crop';
import { useCallback, useEffect, useMemo, useState, type ComponentType } from 'react';
import CropperModule, { type Area, type CropperProps, type Point } from 'react-easy-crop';
import type { OnlineVO } from '@/api/monitor/online/types';
import type { SocialAuthVO } from '@/api/system/social/types';
import type { UserInfoVO, UserProfileForm } from '@/api/system/user/types';
@@ -37,10 +37,14 @@ import defaultAvatar from '@/assets/images/profile.jpg';
import DictTag from '@/components/common/DictTag';
import { useDict } from '@/hooks/useDict';
import { useLoading } from '@/hooks/useLoading';
import { dictOptions } from '@/utils/dict';
import { usePermissionStore } from '@/stores/permissionStore';
import { dictOptions } from '@/utils/dict';
import { getUploadErrorMessage, validateUploadFile } from '@/utils/upload';
// react-easy-crop 6.2.x 的 CommonJS 类型声明遗漏了默认导出,运行时仍为默认组件。
type CropperComponentProps = Partial<CropperProps> & Pick<CropperProps, 'crop' | 'onCropChange'>;
const Cropper = CropperModule as unknown as ComponentType<CropperComponentProps>;
const socialProviders = [
{ source: 'wechat', label: '微信', icon: wechatIcon },
{ source: 'maxkey', label: 'MaxKey', icon: maxkeyIcon },
@@ -329,7 +333,7 @@ export default function Profile() {
<Card title="个人信息">
<Space orientation="vertical" size={18} style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Avatar size={112} src={user.avatar || defaultAvatar} />
<Avatar size={112} src={user.avatarUrl || defaultAvatar} />
<div style={{ marginTop: 12 }}>
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />} loading={avatarUploading}>
+4 -2
View File
@@ -18,6 +18,7 @@ import type { TableQuery, TableVO } from '@/api/tool/gen/types';
import { batchGenCode, delTable, getDataNames, listTable, previewTable, synchDb } from '@/api/tool/gen';
import EllipsisText from '@/components/common/EllipsisText';
import { useDateRangeQuery } from '@/hooks/useDateRangeQuery';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { saveValidatedBlob } from '@/utils/download';
import { hasPermi } from '@/utils/permission';
@@ -26,7 +27,7 @@ import ImportTableModal from './components/ImportTableModal';
function previewName(path: string) {
const fileName = path.substring(path.lastIndexOf('/') + 1);
return fileName.includes('.vm') ? fileName.substring(0, fileName.indexOf('.vm')) : fileName;
return fileName.includes('.ftl') ? fileName.substring(0, fileName.indexOf('.ftl')) : fileName;
}
function previewLanguage(name: string) {
@@ -53,6 +54,7 @@ function previewLanguage(name: string) {
export default function ToolGenPage() {
const location = useLocation();
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1300);
const userInfo = useUserStore(state => state.userInfo);
const [selectedRows, setSelectedRows] = useState<TableVO[]>([]);
const [dataNames, setDataNames] = useState<string[]>([]);
@@ -229,7 +231,7 @@ export default function ToolGenPage() {
actionRef={actionRef}
rowKey="tableId"
columns={columns}
scroll={{ x: 1300 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
pagination={{ defaultCurrent: currentPage, defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{
+3 -1
View File
@@ -16,6 +16,7 @@ import type { CategoryForm, CategoryQuery, CategoryVO } from '@/api/workflow/cat
import { addCategory, delCategory, getCategory, listCategory, updateCategory } from '@/api/workflow/category';
import RowActions from '@/components/common/RowActions';
import { useTreeTableExpand } from '@/hooks/useTreeTableExpand';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { hasPermi } from '@/utils/permission';
import { handleTree } from '@/utils/ruoyi';
@@ -38,6 +39,7 @@ function toTreeSelectData(nodes: CategoryVO[]): CategorySelectNode[] {
export default function WorkflowCategoryPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(900);
const [form] = Form.useForm<CategoryForm>();
const userInfo = useUserStore(state => state.userInfo);
const [categoryOptions, setCategoryOptions] = useState<CategoryVO[]>([]);
@@ -122,7 +124,7 @@ export default function WorkflowCategoryPage() {
actionRef={actionRef}
rowKey="categoryId"
columns={columns}
scroll={{ x: 900 }}
scroll={tableScroll}
pagination={false}
search={{ labelWidth: 90 }}
expandable={{
+3 -1
View File
@@ -19,6 +19,7 @@ import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableExport } from '@/hooks/useTableExport';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -42,6 +43,7 @@ function editableStatus(status?: string) {
export default function WorkflowLeavePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1160);
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('wf_business_status');
const { ids, handleSelectionChange, clearSelection } = useTableSelection<LeaveVO>(row => row.id);
@@ -148,7 +150,7 @@ export default function WorkflowLeavePage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1160 }}
scroll={tableScroll}
search={{ labelWidth: 100 }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async params => {
@@ -32,6 +32,7 @@ import {
import EllipsisText from '@/components/common/EllipsisText';
import TreePanel from '@/components/common/TreePanel';
import { useSearchReset } from '@/hooks/useSearchReset';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { saveValidatedBlob } from '@/utils/download';
import { confirmAction } from '@/utils/modal';
@@ -72,6 +73,7 @@ function requestDefinitionList(
export default function WorkflowProcessDefinitionPage() {
const location = useLocation();
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1240);
const [form] = Form.useForm<FlowDefinitionForm>();
const userInfo = useUserStore(state => state.userInfo);
const [categoryOptions, setCategoryOptions] = useState<CategoryTreeVO[]>([]);
@@ -354,7 +356,7 @@ export default function WorkflowProcessDefinitionPage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1240 }}
scroll={tableScroll}
search={{ labelWidth: 120 }}
form={{ onReset: resetSearch }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
+7 -12
View File
@@ -34,6 +34,7 @@ import UserSelect from '@/components/common/UserSelect';
import { useDict } from '@/hooks/useDict';
import { useLoading } from '@/hooks/useLoading';
import { useSearchReset } from '@/hooks/useSearchReset';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { confirmAction } from '@/utils/modal';
@@ -42,7 +43,6 @@ import { toPageQuery, toTableData } from '@/utils/ruoyi';
type InstanceTab = 'running' | 'finish';
function requestInstanceList(tab: InstanceTab, query: FlowInstanceQuery): Promise<R<PageResult<FlowInstanceVO>>> {
if (tab === 'running') return pageByRunning(query);
return pageByFinish(query);
@@ -60,6 +60,7 @@ function openBusinessForm(row: FlowInstanceVO) {
export default function WorkflowProcessInstancePage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1430);
const [invalidForm] = Form.useForm<{ comment?: string }>();
const [variableForm] = Form.useForm<{ key?: string; value?: string }>();
const userInfo = useUserStore(state => state.userInfo);
@@ -150,7 +151,7 @@ export default function WorkflowProcessInstancePage() {
const openVariable = async (row: FlowInstanceVO) => {
setVariableRow(row);
openVariableModal();
variableForm.resetFields();
if (canVariable) variableForm.resetFields();
await withVariableLoading(async () => {
const res = await instanceVariable(row.id);
setVariableText(res.data.variable || '');
@@ -205,6 +206,7 @@ export default function WorkflowProcessInstancePage() {
{
title: '任务名称',
dataIndex: 'nodeName',
fieldProps: { id: 'workflow-process-instance-node-name' },
width: 150,
render: (_, row) => <EllipsisText value={row.nodeName} maxWidth={130} />
},
@@ -311,7 +313,7 @@ export default function WorkflowProcessInstancePage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1430 }}
scroll={tableScroll}
search={{ labelWidth: 110 }}
form={{ onReset: resetSearch }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
@@ -365,7 +367,7 @@ export default function WorkflowProcessInstancePage() {
open={invalidOpen}
form={invalidForm}
layout="vertical"
modalProps={{ destroyOnHidden: true, onCancel: closeInvalidModal }}
modalProps={{ forceRender: true, onCancel: closeInvalidModal }}
onOpenChange={open => !open && closeInvalidModal()}
onFinish={submitInvalid}
>
@@ -377,14 +379,7 @@ export default function WorkflowProcessInstancePage() {
/>
</ModalForm>
<Modal
title="流程变量"
open={variableOpen}
width={800}
onCancel={closeVariableModal}
footer={null}
destroyOnHidden
>
<Modal title="流程变量" open={variableOpen} width={800} forceRender onCancel={closeVariableModal} footer={null}>
<Card
loading={variableLoading}
title={variableRow?.flowName ? `流程定义名称:${variableRow.flowName}` : '流程变量'}
+3 -1
View File
@@ -19,6 +19,7 @@ import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import { useDict } from '@/hooks/useDict';
import { useTableSelection } from '@/hooks/useTableSelection';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -53,6 +54,7 @@ function buildViewSpel(values: Pick<SpelForm, 'componentName' | 'methodName' | '
export default function WorkflowSpelPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1110);
const [form] = Form.useForm<SpelForm>();
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('sys_normal_disable');
@@ -162,7 +164,7 @@ export default function WorkflowSpelPage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1110 }}
scroll={tableScroll}
search={{ labelWidth: 90 }}
rowSelection={{ selectedRowKeys: ids, onChange: handleSelectionChange }}
request={async params => {
+33 -13
View File
@@ -2,7 +2,7 @@ import { BellOutlined, EyeOutlined, SettingOutlined, SwapOutlined, UserAddOutlin
import { ModalForm, PageContainer, ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import { useBoolean } from 'ahooks';
import { Badge, Button, Form, message, Tabs } from 'antd';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState, type Key } from 'react';
import type { UserVO } from '@/api/system/user/types';
import type { PageResult, R } from '@/api/types';
import type { FlowTaskVO, TaskQuery } from '@/api/workflow/task/types';
@@ -17,12 +17,15 @@ import ProcessMeddle from '@/components/workflow/ProcessMeddle';
import UserNameDisplay from '@/components/workflow/UserNameDisplay';
import { useDict } from '@/hooks/useDict';
import { useSearchReset } from '@/hooks/useSearchReset';
import { useTableScroll } from '@/hooks/useTableScroll';
import { dictOptions } from '@/utils/dict';
import { confirmTitleSafe } from '@/utils/modal';
import { toPageQuery, toTableData } from '@/utils/ruoyi';
type AllTaskTab = 'waiting' | 'finish';
type UserSelectMode = 'applicant' | 'assignee';
type TaskTableRow = FlowTaskVO & {
tableRowKey: string;
};
function requestAllTaskList(tab: AllTaskTab, query: TaskQuery): Promise<R<PageResult<FlowTaskVO>>> {
if (tab === 'waiting') return pageByAllTaskWait(query);
@@ -41,10 +44,12 @@ function openBusinessForm(row: FlowTaskVO) {
export default function WorkflowAllTaskWaitingPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1464);
const [urgeForm] = Form.useForm<{ message: string; messageType: string[] }>();
const dicts = useDict('wf_business_status', 'wf_task_status', 'sys_normal_disable');
const [activeTab, setActiveTab] = useState<AllTaskTab>('waiting');
const [selectedRows, setSelectedRows] = useState<FlowTaskVO[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [selectedRows, setSelectedRows] = useState<TaskTableRow[]>([]);
const [selectedApplicants, setSelectedApplicants] = useState<UserVO[]>([]);
const [userModalOpen, { setTrue: openUserModal, setFalse: closeUserModal }] = useBoolean(false);
const [userSelectMode, setUserSelectMode] = useState<UserSelectMode>('applicant');
@@ -54,7 +59,7 @@ export default function WorkflowAllTaskWaitingPage() {
const businessStatusOptions = useMemo(() => dictOptions(dicts.wf_business_status), [dicts.wf_business_status]);
const taskStatusOptions = useMemo(() => dictOptions(dicts.wf_task_status), [dicts.wf_task_status]);
const selectedTaskIds = selectedRows.map(item => item.id).filter(Boolean);
const selectedTaskIds = [...new Set(selectedRows.map(item => item.id).filter(Boolean))];
const selectedApplicantIds = selectedApplicants.map(item => item.userId).filter(Boolean) as Array<string | number>;
const resetSearch = useSearchReset(
actionRef,
@@ -63,6 +68,7 @@ export default function WorkflowAllTaskWaitingPage() {
const changeTab = (key: string) => {
setActiveTab(key as AllTaskTab);
setSelectedRowKeys([]);
setSelectedRows([]);
setTimeout(() => actionRef.current?.reloadAndRest?.(), 0);
};
@@ -100,6 +106,7 @@ export default function WorkflowAllTaskWaitingPage() {
await updateAssignee(selectedTaskIds, userId);
message.success('操作成功');
closeUserModal();
setSelectedRowKeys([]);
setSelectedRows([]);
actionRef.current?.reload();
return;
@@ -116,6 +123,8 @@ export default function WorkflowAllTaskWaitingPage() {
await urgeTask({ ...values, taskIdList: selectedTaskIds });
message.success('操作成功');
urgeForm.resetFields();
setSelectedRowKeys([]);
setSelectedRows([]);
actionRef.current?.reload();
return true;
};
@@ -125,7 +134,7 @@ export default function WorkflowAllTaskWaitingPage() {
openMeddleModal();
};
const columns: ProColumns<FlowTaskVO>[] = [
const columns: ProColumns<TaskTableRow>[] = [
{
title: '业务编码',
dataIndex: 'businessCode',
@@ -170,6 +179,7 @@ export default function WorkflowAllTaskWaitingPage() {
{
title: '任务名称',
dataIndex: 'nodeName',
fieldProps: { id: 'workflow-all-task-node-name' },
width: 150,
render: (_, row) => <EllipsisText value={row.nodeName} maxWidth={130} />
},
@@ -206,7 +216,7 @@ export default function WorkflowAllTaskWaitingPage() {
search: false,
width: 120,
fieldProps: { options: taskStatusOptions },
render: (_: unknown, row: FlowTaskVO) => (
render: (_: unknown, row: TaskTableRow) => (
<DictTag options={dicts.wf_task_status} value={row.flowTaskStatus} />
)
}
@@ -244,17 +254,20 @@ export default function WorkflowAllTaskWaitingPage() {
{ key: 'finish', label: '已办任务' }
]}
/>
<ProTable<FlowTaskVO, TaskQuery>
<ProTable<TaskTableRow, TaskQuery>
actionRef={actionRef}
rowKey="id"
rowKey="tableRowKey"
columns={columns}
scroll={{ x: 1464 }}
scroll={tableScroll}
search={{ labelWidth: 100 }}
form={{ onReset: resetSearch }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
rowSelection={{
selectedRowKeys: selectedTaskIds,
onChange: (_, rows) => setSelectedRows(rows)
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys);
setSelectedRows(rows);
}
}}
request={async params => {
const query = toPageQuery(params);
@@ -262,7 +275,14 @@ export default function WorkflowAllTaskWaitingPage() {
...query,
createByIds: selectedApplicantIds
});
return toTableData(res);
const tableData = toTableData(res);
return {
...tableData,
data: tableData.data.map((row, index) => ({
...row,
tableRowKey: `${activeTab}:${query.pageNum || 1}:${index}`
}))
};
}}
toolbar={{ title: activeTab === 'waiting' ? '全部待办任务' : '全部已办任务' }}
toolBarRender={() => [
@@ -317,7 +337,7 @@ export default function WorkflowAllTaskWaitingPage() {
form={urgeForm}
layout="vertical"
initialValues={{ messageType: ['1'] }}
modalProps={{ destroyOnHidden: true, onCancel: closeUrgeModal }}
modalProps={{ forceRender: true, onCancel: closeUrgeModal }}
onOpenChange={open => !open && closeUrgeModal()}
onFinish={submitUrge}
>
+3 -1
View File
@@ -12,6 +12,7 @@ import EllipsisText from '@/components/common/EllipsisText';
import RowActions from '@/components/common/RowActions';
import TreePanel from '@/components/common/TreePanel';
import { useDict } from '@/hooks/useDict';
import { useTableScroll } from '@/hooks/useTableScroll';
import { useUserStore } from '@/stores/userStore';
import { dictOptions } from '@/utils/dict';
import { hasPermi } from '@/utils/permission';
@@ -34,6 +35,7 @@ function openBusinessForm(row: FlowInstanceVO, type: 'update' | 'view') {
export default function WorkflowMyDocumentPage() {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1240);
const userInfo = useUserStore(state => state.userInfo);
const dicts = useDict('wf_business_status');
const [categoryOptions, setCategoryOptions] = useState<CategoryTreeVO[]>([]);
@@ -167,7 +169,7 @@ export default function WorkflowMyDocumentPage() {
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1240 }}
scroll={tableScroll}
search={{ labelWidth: 120 }}
form={{ onReset: resetSearch }}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
+4 -2
View File
@@ -14,6 +14,7 @@ import RowActions from '@/components/common/RowActions';
import UserSelect from '@/components/common/UserSelect';
import UserNameDisplay from '@/components/workflow/UserNameDisplay';
import { useDict } from '@/hooks/useDict';
import { useTableScroll } from '@/hooks/useTableScroll';
import { dictOptions } from '@/utils/dict';
import { toPageQuery, toTableData } from '@/utils/ruoyi';
@@ -29,7 +30,6 @@ const pageTitle: Record<TaskListType, string> = {
copy: '抄送任务'
};
function requestTaskList(type: TaskListType, query: TaskQuery): Promise<R<PageResult<FlowTaskVO>>> {
if (type === 'wait') return pageByTaskWait(query);
if (type === 'finish') return pageByTaskFinish(query);
@@ -48,6 +48,7 @@ function openBusinessForm(row: FlowTaskVO, type: 'approval' | 'view') {
export default function WorkflowTaskListPage({ type }: WorkflowTaskListPageProps) {
const actionRef = useRef<ActionType | undefined>(undefined);
const { tableScroll } = useTableScroll(1460);
const dicts = useDict('wf_business_status', 'wf_task_status');
const [selectedApplicants, setSelectedApplicants] = useState<UserVO[]>([]);
const [applicantOpen, { setTrue: openApplicantModal, setFalse: closeApplicantModal }] = useBoolean(false);
@@ -96,6 +97,7 @@ export default function WorkflowTaskListPage({ type }: WorkflowTaskListPageProps
{
title: '任务名称',
dataIndex: 'nodeName',
fieldProps: { id: `workflow-task-${type}-node-name` },
width: 150,
render: (_, row) => <EllipsisText value={row.nodeName} maxWidth={130} />
},
@@ -210,7 +212,7 @@ export default function WorkflowTaskListPage({ type }: WorkflowTaskListPageProps
actionRef={actionRef}
rowKey="id"
columns={columns}
scroll={{ x: 1460 }}
scroll={tableScroll}
search={{ labelWidth: 100 }}
form={{ onReset: resetSearch }}
request={async params => {
+3 -1
View File
@@ -17,6 +17,7 @@ export interface LayoutSettingsValue {
fixedHeader: boolean;
sidebarLogo: boolean;
dynamicTitle: boolean;
fullHeightTable: boolean;
}
export const layoutSettingsStorageKey = 'layout-setting-react';
@@ -33,7 +34,8 @@ export const defaultLayoutSettings: LayoutSettingsValue = {
tagsIcon: true,
fixedHeader: true,
sidebarLogo: true,
dynamicTitle: true
dynamicTitle: true,
fullHeightTable: true
};
export function loadLayoutSettings(): LayoutSettingsValue {
+11 -1
View File
@@ -31,9 +31,19 @@ function sortNotices(notices: NoticeItem[]) {
return [...notices].sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0));
}
function dedupeNotices(notices: NoticeItem[]) {
const messageIds = new Set<string>();
return notices.filter(notice => {
const messageId = String(notice.messageId);
if (messageIds.has(messageId)) return false;
messageIds.add(messageId);
return true;
});
}
export const useNoticeStore = create<NoticeState>(set => ({
notices: [],
setNotices: notices => set({ notices: sortNotices(notices) }),
setNotices: notices => set({ notices: sortNotices(dedupeNotices(notices)) }),
addNotice: notice =>
set(state => {
const key = noticeKey(notice);
+1 -1
View File
@@ -17,7 +17,7 @@ export const useUserStore = create<UserState>(set => ({
...userInfo,
user: {
...userInfo.user,
avatar: userInfo.user.avatar || defaultAvatar
avatarUrl: userInfo.user.avatarUrl || defaultAvatar
}
}
: undefined
+41 -2
View File
@@ -14,6 +14,9 @@ let webSocket: WebSocket | undefined;
let reconnectTimer: number | undefined;
let reconnectAttempts = 0;
let pushClosed = true;
const KICKED_MESSAGE = 'kicked';
let pushKicked = false;
let resumePushTimer: number | undefined;
function messageEnabled() {
return appEnv.messageEnabled;
@@ -67,6 +70,15 @@ function appendNotice(raw: string) {
notification.success({ message: title, description: notice.message, duration: 3 });
}
function handlePushMessage(raw: string) {
if (raw === KICKED_MESSAGE) {
pushKicked = true;
closePush();
return;
}
appendNotice(raw);
}
function buildHttpUrl(path: string) {
const base = appEnv.baseApi;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
@@ -108,7 +120,7 @@ function initSsePush(path: string) {
reconnectAttempts = 0;
};
eventSource.onmessage = event => {
if (event.data) appendNotice(event.data);
if (event.data) handlePushMessage(event.data);
};
eventSource.onerror = () => {
eventSource?.close();
@@ -128,7 +140,7 @@ function initWsPush(path: string) {
};
webSocket.onmessage = event => {
if (String(event.data) === 'pong') return;
appendNotice(String(event.data));
handlePushMessage(String(event.data));
};
webSocket.onclose = () => scheduleReconnect(connect, 3, 1000);
};
@@ -153,6 +165,7 @@ export async function initMessageBox() {
export function initPush() {
closePush();
if (!messageEnabled()) return;
pushKicked = false;
pushClosed = false;
if (appEnv.messageTransport === 'websocket') {
initWsPush(appEnv.messagePath);
@@ -182,6 +195,32 @@ export function closePush() {
reconnectAttempts = 0;
}
function resumePushIfNeeded() {
if (!pushKicked || !getToken() || document.visibilityState !== 'visible') {
return;
}
if (resumePushTimer) {
window.clearTimeout(resumePushTimer);
}
resumePushTimer = window.setTimeout(async () => {
resumePushTimer = undefined;
if (!pushKicked || !getToken() || document.visibilityState !== 'visible') {
return;
}
try {
await initMessageBox();
} finally {
initPush();
}
}, 300);
}
if (typeof window !== 'undefined') {
window.addEventListener('focus', resumePushIfNeeded);
document.addEventListener('visibilitychange', resumePushIfNeeded);
window.addEventListener('online', resumePushIfNeeded);
}
export function pushHeaders() {
return globalHeaders();
}