Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e11acb4f | ||
|
|
0697f1fafb | ||
|
|
903c34801a | ||
|
|
b1661081d6 | ||
|
|
830eacbf02 | ||
|
|
497a317e90 | ||
|
|
e74984e0e0 | ||
|
|
a1069624e7 | ||
|
|
836cc40d43 | ||
|
|
512a2ab86e | ||
|
|
b4f5fe82a2 | ||
|
|
8f217a2cc8 | ||
|
|
516efedfa0 | ||
|
|
6c7ec5d3f1 | ||
|
|
a1ab12e70c | ||
|
|
2b8793199d | ||
|
|
51cf3e2b19 | ||
|
|
d155f09e79 | ||
|
|
d10da5d62a |
@@ -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` 约定。
|
||||
|
||||
@@ -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 页面。
|
||||
|
||||
验证要求:
|
||||
|
||||
|
||||
@@ -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` 写入模板。
|
||||
|
||||
@@ -46,7 +46,7 @@ description: 复杂前端页面增强专家。用于修改当前 plus-ui-react
|
||||
- 如果页面已有导出参数缓存,继续通过 `updateExportParams` 更新。
|
||||
- 如果页面已有树筛选状态,查询参数要合并该状态,不要覆盖掉。
|
||||
- 如果页面已有特殊禁用规则、超级管理员保护或业务状态判断,必须保留。
|
||||
- 如果增强的是标准 CRUD 能力或公共工具约定,要评估当前仓库 `gen/*.vm` 是否也应同步更新。
|
||||
- 如果增强的是标准 CRUD 能力或公共工具约定,要评估当前仓库 `gen/*.ftl` 是否也应同步更新。
|
||||
|
||||
## 自检
|
||||
|
||||
|
||||
@@ -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 习惯。
|
||||
|
||||
也就是说:
|
||||
|
||||
@@ -92,7 +92,3 @@ export function del${BusinessName}(${pkColumn.javaField}: string | number | Arra
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,3 @@ export interface ${BusinessName}Query<#if !table.tree> extends PageQuery</#if> {
|
||||
*/
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+18
-17
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2269
-2034
File diff suppressed because it is too large
Load Diff
+3
-143
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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%),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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('配置已保存')}>
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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']}>
|
||||
|
||||
+50
-511
@@ -1,532 +1,71 @@
|
||||
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 res = await registerCurrentSnailUser();
|
||||
if (!res.data?.openId) {
|
||||
setChatUrl('');
|
||||
setLoadError('获取 AI 用户身份失败');
|
||||
return;
|
||||
}
|
||||
|
||||
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 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}
|
||||
>
|
||||
新对话
|
||||
) : null}
|
||||
{chatUrl ? (
|
||||
<iframe className="ai-chat-frame" src={chatUrl} title="Snail AI" allow="clipboard-read; clipboard-write" />
|
||||
) : (
|
||||
<Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}>
|
||||
{loadError ? (
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={loadChat}>
|
||||
重新加载
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<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))}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
) : null}
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}` : '流程变量'}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user