Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed4b537ad | |||
| 3988eaf910 | |||
| e9214137c1 | |||
| 13b74cfe97 | |||
|
|
ab882e085b | ||
| 62859bfc38 | |||
| ba328e02bb | |||
|
|
28d597d91e | ||
|
|
fe29fde564 | ||
|
|
7d578ab271 | ||
|
|
71da2d507e | ||
| acd41555f9 | |||
| 2367e03146 | |||
|
|
023490c012 | ||
|
|
29ef03c40f | ||
| 387eb41412 | |||
|
|
480714172e | ||
|
|
0c6ed249ee | ||
| 543d1a59a9 | |||
|
|
3ad30b4f39 | ||
|
|
14e0502d16 | ||
|
|
d43f999b96 | ||
|
|
8b34147868 | ||
| 7a4d831c10 | |||
|
|
3a064eb09f | ||
| 960fe805ec | |||
| 59b73f3dae | |||
| ddd05f8c02 | |||
| f634d21d2a | |||
| e3a456debd | |||
| 60debcda8a | |||
| 5615399a68 | |||
| 28c47b14a3 | |||
| 5947157f89 | |||
| f0ea903d59 | |||
| 824392b564 | |||
| f4f43814b3 | |||
| 991cbb5278 | |||
| 67ef8af3fa | |||
| 89cdc62eaa | |||
| 3cd91b01e1 | |||
| 174d352280 | |||
| 0fca2f8c0d | |||
| 5b9c7e781b | |||
| 4122dfa50d | |||
| c5911ea34b | |||
| ca1756344c | |||
| b4878845da | |||
| b265d0d4f1 | |||
| 497a0906cf | |||
| e22f6550ae | |||
| a6fc7b48dc | |||
| 9b6f5955c3 | |||
| b6a50563bc | |||
| fb48977867 |
4
.env
4
.env
@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
|
||||
|
||||
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
|
||||
# 典型场景:token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
|
||||
VITE_SERVICE_LOGOUT_CODES=401,1002023000
|
||||
VITE_SERVICE_LOGOUT_CODES=401
|
||||
|
||||
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
|
||||
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
|
||||
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
|
||||
|
||||
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
|
||||
# 典型场景:accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
|
||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001
|
||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
|
||||
|
||||
# 静态路由模式下定义的超级管理员角色
|
||||
VITE_STATIC_SUPER_ROLE=R_SUPER
|
||||
|
||||
2
.env.dev
2
.env.dev
@@ -6,5 +6,5 @@ VITE_OTHER_SERVICE_BASE_URL= `{
|
||||
"demo": "http://localhost:9528"
|
||||
}`
|
||||
|
||||
# 鏄惁鍦ㄥ紑鍙戠幆澧冨惎鐢?Vue DevTools 娴姩鍏ュ彛
|
||||
# 是否在开发环境启用 Vue DevTools 浮动入口
|
||||
VITE_DEVTOOLS_ENABLED=N
|
||||
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -11,3 +11,4 @@
|
||||
"*.md" eol=lf
|
||||
"*.yaml" eol=lf
|
||||
"*.yml" eol=lf
|
||||
".*" text eol=lf
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -37,3 +37,10 @@ yarn.lock
|
||||
# Docs
|
||||
/docs/*
|
||||
!/docs/frontend-page-resource-manifest.json
|
||||
|
||||
# Claude
|
||||
/.claude/*
|
||||
|
||||
# Temp
|
||||
/codeTemp/*
|
||||
SKILL.md
|
||||
|
||||
321
AGENTS.md
Normal file
321
AGENTS.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件为后续编码代理提供 `cn-rdms-web` 的稳定仓库上下文。
|
||||
在修改代码前请先阅读。
|
||||
|
||||
## 适用范围
|
||||
|
||||
本说明适用于以 `C:\code\gitea\rdms\cn-rdms-web` 为根目录的整个仓库。
|
||||
|
||||
描述仓库现状时,以当前代码、当前配置、当前文档中可直接验证的事实为准;除非用户明确要求,不引入历史实现、过渡方案或猜测来解释当前行为。
|
||||
|
||||
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
|
||||
|
||||
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
|
||||
|
||||
## 交互与执行原则
|
||||
|
||||
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
|
||||
- 先定义验证方式,再执行修改和校验;如果没有实际运行命令,需要明确说明只做了静态检查。
|
||||
- 只在当前任务需要的最小范围内改动,避免把无关重构混入同一次修改。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 应用类型:RDMS 系统的 Vue 3 后台前端
|
||||
- 包管理器:`pnpm`
|
||||
- 运行时与工具链:Vite 7、TypeScript、Pinia、Element Plus、UnoCSS
|
||||
- 工作区包位于 `packages/*`,通过 `@sa/*` 引用
|
||||
- Node 版本要求:`>=20.19.0`
|
||||
- pnpm 版本要求:`>=8.7.0`
|
||||
|
||||
## 项目骨架主线
|
||||
|
||||
当前项目不是边写边拼的页面集合,而是已经形成闭环的后台前端骨架。后续改动优先顺着这几条主线做,而不是平行再起一套:
|
||||
|
||||
- 路由来源统一:页面文件与自定义路由作为源头,经 `elegant-router` 生成路由产物,再由 `build/plugins/router.ts` 集中补齐 `meta`
|
||||
- 权限入口统一:常量路由和权限路由明确分流,`route store` 负责初始化、菜单生成、缓存路由和面包屑
|
||||
- 请求入口统一:所有业务请求默认走 `src/service/request/index.ts`
|
||||
- 页面套路统一:列表页通常拆为搜索区、表格区、操作弹层/抽屉和 `modules/*` 子组件
|
||||
- 衍生资产统一:页面资源白名单从路由结构生成,不手工维护第二份页面清单
|
||||
|
||||
## 环境与构建说明
|
||||
|
||||
- Vite 路径别名:`@` -> `src`
|
||||
- Vite 路径别名:`~` -> 仓库根目录
|
||||
- 开发服务器默认端口:`9527`
|
||||
- 预览服务器默认端口:`9725`
|
||||
- 环境文件包括 `.env`、`.env.dev`、`.env.prod`
|
||||
|
||||
## 关键目录与文件
|
||||
|
||||
- `src/views`:业务页面
|
||||
- `src/components`:共享组件
|
||||
- `src/layouts`:应用壳、头部、侧栏、菜单、标签页、主题抽屉
|
||||
- `src/store/modules`:Pinia 模块,包含 app、auth、route、tab、theme
|
||||
- `src/service/api`:接口封装与请求参数拼装
|
||||
- `src/service/request`:统一请求实例、鉴权头、加密、错误处理、token 刷新
|
||||
- `src/router/routes`:自定义路由定义
|
||||
- `src/router/elegant`:自动生成的路由产物
|
||||
- `src/theme/settings.ts`:默认主题与布局设置
|
||||
- `build/plugins/router.ts`:elegant-router 配置与路由元信息生成逻辑
|
||||
- `src/hooks/common/table.ts`:列表页表格 hook 主入口
|
||||
- `src/hooks/common/form.ts`:表单校验与表单实例 hook
|
||||
- `src/constants/status-tag.ts`:业务对象状态颜色(ElTag type)集中配置
|
||||
- `src/styles/scss/element-plus.scss`:当前项目表格、弹层、按钮、表单密度与公共壳样式标准
|
||||
- `packages/*`:项目内本地共享库
|
||||
- `docs/`:当前工作上下文的一部分,做架构级、权限级、页面规范级改动前优先查阅
|
||||
|
||||
## 生成文件
|
||||
|
||||
除非有非常明确的理由并且同步维护生成流程,否则不要手工修改生成文件。
|
||||
|
||||
- `src/router/elegant/imports.ts`
|
||||
- `src/router/elegant/routes.ts`
|
||||
- `src/router/elegant/transform.ts`
|
||||
- `src/typings/elegant-router.d.ts`
|
||||
- `src/typings/components.d.ts`
|
||||
- `docs/frontend-page-resource-manifest.json`
|
||||
|
||||
如果路由生成产物过期或不一致,执行 `pnpm gen-route`。
|
||||
如果页面资源清单需要同步,执行 `pnpm gen:page-resource-manifest`。
|
||||
|
||||
## 路由与导航开发口径
|
||||
|
||||
- 新增业务页面时,优先通过页面文件与 `build/plugins/router.ts` 补齐路由,不要手工在多个位置重复注册同一页面。
|
||||
- 路由 `meta` 的中心落点是 `build/plugins/router.ts`;新增业务页的 `icon`、`order`、`roles`、`keepAlive` 优先在那里集中维护。
|
||||
- 当前代码链路仍保留 `i18nKey` 兼容字段,但它是兼容保留项,不是新增业务页面必须补齐的默认要求。
|
||||
- `meta.constant = true` 的路由属于常量路由;其余默认属于权限路由。
|
||||
- 常量路由维护入口优先是 `build/plugins/router.ts` 和 `src/router/routes/custom-routes.ts`,不要把常量路由散落到业务页面逻辑里。
|
||||
- 菜单图标约定属于路由契约的一部分:`meta.icon` 表示 Iconify 图标,`meta.localIcon` 表示本地 SVG 图标;不要混用字段语义。
|
||||
|
||||
### 对象上下文业务域入口页口径
|
||||
|
||||
- `product`、`project` 这类对象上下文业务域的入口页,按 `docs/rdms/rdms-object-context-navigation-implementation-notes.md` 口径,本来就是“先进入业务域入口页,再选择对象建立上下文”;不要把“入口页是可点击菜单”误判成配置错误。
|
||||
- 对象上下文业务域的“入口态”页面,例如 `product_list -> /product/list -> view.product_list`,可以作为左侧一级入口菜单实际命中的页面;这不等于已经进入对象上下文态。
|
||||
- 不要为了修复“点击入口页后只剩内容页、布局壳消失”的问题,直接要求把对象域入口菜单从“菜单”改成“目录”。先检查当前是不是动态权限路由模式,以及后端 `get-user-routes` 返回是否缺少业务域根路由。
|
||||
- 在 `VITE_AUTH_ROUTE_MODE=dynamic` 下,如果后端只返回对象域入口叶子页,而没有返回本地静态骨架中的业务域根路由,例如缺少 `product -> layout.base`、只返回 `product_list -> view.product_list`,前端必须在动态路由归一化阶段补回本地业务域骨架,而不是让入口页直接裸挂成顶层 `view.*` 路由。
|
||||
- 对象上下文业务域的稳定来源仍应是本地路由骨架:业务域根路由负责 `layout.base`,入口页负责对象列表或对象选择,真正的对象功能页继续挂在该业务域下。动态路由兼容逻辑只能做“补骨架”和“对齐入口”,不要反过来推翻这层结构。
|
||||
- 后续新增新的对象上下文业务域时,至少同步检查这几处是否闭环:本地静态路由骨架、`src/constants/object-context.ts` 中的 `domainKey / entryRouteKey / entryRoutePath / fallbackDefaultRouteKey`、动态路由归一化逻辑、对象上下文 store 与头部菜单切换逻辑。
|
||||
|
||||
## 分层职责约束
|
||||
|
||||
### `src/views`
|
||||
|
||||
- 页面层负责页面编排、交互状态、表单行为和对 store/service 的组合调用。
|
||||
- 不要在页面组件里散落 URL 拼接、token 注入、统一错误提示或权限路由推导逻辑。
|
||||
|
||||
### `src/components`
|
||||
|
||||
- 共享组件负责可复用 UI 或局部业务部件。
|
||||
- 不要把只服务于单个页面的复杂流程长期堆在公共组件目录中。
|
||||
|
||||
### `src/service/api`
|
||||
|
||||
- API 层负责接口封装、请求参数归一化、查询字符串拼装和返回类型对齐。
|
||||
- 不要在 `views`、`store` 或 `components` 中重复手写同一接口地址和参数序列化逻辑。
|
||||
|
||||
### `src/service/request`
|
||||
|
||||
- 请求层负责统一请求实例、鉴权头、接口加密、成功码判定、token 刷新和通用错误处理。
|
||||
- 除非任务明确需要,不要平行引入新的 `axios`/`fetch` 调用链绕开现有封装。
|
||||
|
||||
### `src/store/modules`
|
||||
|
||||
- Store 负责跨页面共享状态,例如认证、路由、标签页、主题、布局和全局 UI 状态。
|
||||
- 临时性的页面局部状态优先留在页面组件或 composable 中,不要无边界堆进全局 store。
|
||||
|
||||
### `src/router` 与 `build/plugins/router.ts`
|
||||
|
||||
- 路由、菜单、权限标识、首页配置和路由元信息优先沿用当前 elegant-router 与 route store 链路。
|
||||
- 不要只在页面里临时写条件分支来替代正式的路由、菜单或权限配置。
|
||||
|
||||
### `src/layouts` 与 `src/theme`
|
||||
|
||||
- 布局壳和主题设置是全局行为源头,相关改动要同时检查布局组件、theme store 和默认设置。
|
||||
- 不要在业务页面里复制一套平行的布局状态或主题状态。
|
||||
|
||||
## 业务页面开发风格
|
||||
|
||||
- 页面组件保持“编排层薄”。页面文件主要负责搜索参数、表格 hook、列定义、弹层开关、接口调用编排,不把大量表单细节和重复交互直接堆在页面根组件里。
|
||||
- 列表页优先拆出同目录下的 `modules/*` 子组件,例如搜索组件、操作弹层、详情抽屉、资源面板等。
|
||||
- 系统管理下现有 `user`、`role`、`menu`、`dict` 页面可以作为参考实现,新增同类页面优先沿用它们的拆分方式。
|
||||
- 新增或触达列表页搜索组件时,必须按 `docs/table-search-fields-usage.md` 使用 `src/components/custom/table-search-fields.vue` 的 `fields` 声明式配置,不得手写 `ElRow / ElCol / ElFormItem` 搜索区骨架;只有字段存在复杂联动、自定义插槽或 `TableSearchFields` 明确无法承载时,才允许退回 `src/components/custom/table-search-panel.vue`,并需要在实施说明中写明原因。搜索模块本身应尽量只接收 `model` 和必要选项,只向外发出 `reset` / `search`,不直接承载列表请求逻辑。
|
||||
- 列表能力优先复用 `src/hooks/common/table.ts` 中的 `useUIPaginatedTable`、`useTableOperate`、`defaultTransform`。
|
||||
- 表单能力优先复用 `src/hooks/common/form.ts` 中的 `useForm`、`useFormRules`。
|
||||
- 当前项目的真实业务口径是“内网中文优先”。新增业务页不必为了形式强行补全国际化键;但如果是在已有大量 `$t(...)` 的页面或模块内继续开发,优先保持该局部代码风格一致,不要半页中文直写、半页国际化混用。
|
||||
|
||||
## 表格、搜索区与操作列约束
|
||||
|
||||
- 搜索区按钮组必须固定在第一行最后一个位置;存在折叠项时,按钮顺序保持为“展开/收起 -> 重置 -> 查询”。这是 `TableSearchFields` 的布局契约,不允许因为查询条件不足、展开/收起或响应式样式把按钮提前到中间位置或挤到后续行。
|
||||
- 不要在每个页面重新拼一套搜索区骨架;常规查询条件必须使用 `TableSearchFields`,通过 `columns` 控制每行格子数和折叠阈值。`columns` 表示首行总格数,其中最后 1 格永远留给按钮区;字段不足 `columns - 1` 时由公共组件补空占位,字段超过时剩余字段进入展开区。类似项目管理入口页这类 4 个查询条件的场景,必须使用 `:columns="4"`,形成“3 个条件 + 按钮区”的首行布局。
|
||||
- 表格操作列优先复用 `src/components/custom/business-table-action-cell.tsx`。
|
||||
- 操作数 `<= 2` 时默认直出;操作数 `> 2` 时优先收敛为 `1 个直出主按钮 + 1 个更多按钮`。
|
||||
- 新增列表页如果使用 `ElCard` 承载需要撑满剩余高度的 `ElTable height="100%"`,`body-class` 优先使用公共类 `business-table-card-body`,该类由 `src/styles/scss/element-plus.scss` 统一维护;不要再为每个页面新增 `xxx-table-card-body` 私有样式。历史页面已有私有类时不强制专项回改,当前任务触达相关页面再按公共类收敛。
|
||||
- 表格、按钮、弹层、表单的尺寸和间距标准优先由 `src/styles/scss/element-plus.scss` 和公共组件承接,不在业务页面散落写新的局部尺寸作为事实标准。
|
||||
|
||||
## 表单与弹层约束
|
||||
|
||||
- 新增、编辑能力优先沿用 `ElDialog / ElDrawer / ElForm / ElScrollbar / #footer` 这一套标准组合,不额外创造新的弹层交互模型。
|
||||
- 轻中量表单优先复用 `src/components/custom/business-form-dialog.vue`;字段较多、需要保留列表上下文或承载重型控件时,再考虑 `src/components/custom/business-form-drawer.vue`。
|
||||
- 表单分组优先复用 `src/components/custom/business-form-section.vue`。
|
||||
- `dialog` 宽度优先按纯表单字段数分三档:`<= 6` 个字段用 `sm`,默认单列,目标宽度 `520px`;`7 ~ 14` 个字段用 `md`,默认双列,目标宽度 `720px`;`> 14` 个字段用 `lg`,仍以双列为主,目标宽度 `960px`。宽度只做响应式收缩,实际宽度不超过 `calc(100vw - 32px)`;不因为单个 `textarea` 自动升档,也不做列数响应式折叠。
|
||||
- 常规 CRUD 表单优先使用 `label-position="top"`、`ElRow + ElCol` 双列布局、`gutter=16`;普通字段优先 `span=12`,长文本或重量级字段优先 `span=24`。如果整体字段数 `<= 6`,默认按单列表单理解。
|
||||
- 当纯表单 `dialog` 因字段数 `<= 6` 归入 `sm` 时,不能只改 `preset`;字段布局也要同步落到单列,常规 `ElCol` 应使用 `span=24`,除非该弹框已经被明确判定为复合内容特例。
|
||||
- 左右分栏、表单 + 表格、表单 + 树、关系编辑器、时间线、大段说明区这类复合内容 `dialog`,不强行按字段数归类;可按内容复杂度单独评估使用 `md`、`lg` 或更宽值,但只有在无法合理归入“纯表单三档”时才允许特例。
|
||||
- 禁止用页面级宽范围样式直接覆盖整页 `.business-form-dialog` 来统一放大弹框;如确实需要特殊宽度,只能精确作用于目标弹框,且不能误伤同页面其他 `dialog`。
|
||||
- 底部按钮顺序固定为“取消 -> 确认”,并保持右对齐。
|
||||
- 单选组和开关类字段优先复用仓库既有样式钩子,例如 `business-form-radio-group`、`business-form-switch-field`。
|
||||
- 权限控制按钮默认采用“无权限不渲染”口径,不要把纯权限不足的入口做成禁用态再展示给用户;只有业务状态暂时不可操作、但仍需让用户感知入口存在时,才允许保留禁用态。
|
||||
|
||||
## 接口、路由与权限约束
|
||||
|
||||
- 默认沿用 `src/service/request/index.ts` 中现有请求链路,不要另造一套鉴权、加密、错误处理或 token 刷新机制。
|
||||
- 接口前缀、服务常量优先复用现有常量定义,例如 `src/constants/service.ts`。
|
||||
- 后端契约变化时,至少同步检查 `src/service/api/*`、`src/typings/api/*`、相关页面调用和说明文档是否一致。
|
||||
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*` 和相关文档。
|
||||
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
|
||||
|
||||
## 防重复提交(两层联防)
|
||||
|
||||
用户快速双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮内置无 loading 等场景,都可能让同一写操作发出多次。仓库采用两层防御,新增写操作功能时按顺序检查:
|
||||
|
||||
### 第一层:业务按钮的 loading 锁(视觉防御)
|
||||
|
||||
- 新增、编辑入口优先使用 `src/components/custom/business-form-dialog.vue` 或 `src/components/custom/business-form-drawer.vue`,它们在 `submit` 流程内 await 接口期间会自动将"确认"按钮置为 `loading` + `disabled`。
|
||||
- 不要裸手写 `<ElButton @click="submit">` 直接调接口;若必须使用裸 `ElButton`,需要自行绑定 `:loading` 并在 await 接口期间锁住按钮。
|
||||
- 删除二次确认使用 `ElMessageBox.confirm` 时,其内部"确定"按钮没有 loading 能力,必须依赖第二层兜底,不要尝试改造 confirm 的内部按钮。
|
||||
|
||||
### 第二层:请求层全局去重(逻辑兜底)
|
||||
|
||||
- 入口:`src/service/request/dedupe.ts` 提供 `withDedupe`,已在 `src/service/request/index.ts` 包住统一的 `request` 实例;`demoRequest` 未启用。
|
||||
- 指纹:`method + 完整 URL + 排序后的 params + 稳定序列化的 body`;body 内对象按 key 排序,数组保序。
|
||||
- 行为:写操作(`POST` / `PUT` / `DELETE` / `PATCH`)在第一次请求 pending 期内,若再次发起指纹相同的请求,自动复用第一次的 Promise,不发出第二次实际请求;调用方两次拿到完全相同的返回对象。
|
||||
- 跳过条件(即不去重,按原逻辑发出):`GET` / `HEAD` / `OPTIONS`,请求体为 `FormData` 或 `Blob`(上传场景),调用方显式传 `{ dedupe: false }`。
|
||||
- 业务调用方零感知:新增接口默认即享受兜底,不需要在 `src/service/api/*` 或页面层做任何改动。
|
||||
- 极少数业务确实允许短时间内并发提交完全相同的写请求时,在调用处显式传 `request({ ..., dedupe: false })` 单接口关闭。
|
||||
- 兜底超时 30 秒:极端情况下若某次 Promise 未 settle,pending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
|
||||
|
||||
### 设计责任划分
|
||||
|
||||
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
|
||||
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
|
||||
|
||||
## 运行时字典使用口径
|
||||
|
||||
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
|
||||
- 字典编码常量优先收敛在 `src/constants/dict.ts`,不要在页面里散落硬编码 `dictType`。
|
||||
- 不要猜测字典编码。新增某个业务字段对应的字典前,先从“后端接口文档、后端字段契约、系统字典管理页”确认真实 `dictType`,再写入 `src/constants/dict.ts`。
|
||||
- `src/constants/dict.ts` 中每个导出的字典常量,尽量补中文注释,至少说明两件事:对应哪个业务字段、这个编码是从哪里确认出来的。
|
||||
- 如果后端实际 `dictType` 带有历史命名痕迹,例如当前对象方向仍叫 `rdms_product_direction`,前端常量名优先按真实业务语义命名,不要继续把历史误导扩散到页面代码里。
|
||||
- 表单下拉优先使用 `src/components/custom/dict-select.vue`。
|
||||
- 普通文案回显优先使用 `src/components/custom/dict-text.vue`。
|
||||
- 需要标签态回显时优先使用 `src/components/custom/dict-tag.vue`,标签颜色仍由业务页面自己决定。
|
||||
- 在 `script setup`、TSX 列格式化、复杂判断里,优先使用 `src/hooks/business/dict.ts` 提供的 `useDict(dictCode)`,常用能力包括 `dictOptions`、`getItem`、`getLabel`、`getLabels`、`hasValue`。
|
||||
- `DictSelect` 默认只展示启用项;确实需要包含禁用项时,显式传 `:only-enabled="false"`。
|
||||
|
||||
简单示例:
|
||||
|
||||
```vue
|
||||
<DictSelect v-model="form.directionCode" :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" />
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="row.directionCode" />
|
||||
<DictTag :dict-code="SYSTEM_USER_COMPANY_DICT_CODE" :value="row.companyCode" type="info" />
|
||||
```
|
||||
|
||||
```ts
|
||||
const { getLabel, getLabels } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
|
||||
const directionLabel = getLabel(row.directionCode);
|
||||
const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
```
|
||||
|
||||
确认字典编码的典型方式:
|
||||
|
||||
- 后端接口文档直接写明字段使用哪个字典,例如产品 `directionCode -> rdms_product_direction`;如果该编码只是历史命名,前端常量名仍按通用业务语义命名。
|
||||
- 当前系统已有页面或接口已经稳定使用某个字典,例如用户所属公司 `company -> system_user_company`。
|
||||
- 如果以上两种都没有,就先让后端或业务明确 `dictType`,不要前端自己命名。
|
||||
|
||||
## 业务对象状态颜色集中口径
|
||||
|
||||
- 各业务域(产品、项目、需求、任务、执行、工单等)的 `statusCode -> ElTag type` 集中维护在 `src/constants/status-tag.ts`,不要在各业务页面或模块内散落硬编码同一份映射。
|
||||
- 通用入口是 `getStatusTagType(domain, statusCode)`,未匹配的 `statusCode` 默认回退到 `'info'`。
|
||||
- 业务模块按域写薄包装暴露给页面调用,例如 `getExecutionStatusTagType(code)` 内部调用 `getStatusTagType('projectExecution', code)`,避免页面直接耦合到 domain 字符串。
|
||||
- 新增对象域时同步两处:`StatusDomain` 增加枚举值;`statusTagTypeRegistry` 添加对应 `statusCode -> StatusTagType` 映射。
|
||||
- 后端契约:未来若状态字典开始返回颜色字段,调用方应优先使用后端值,缺失时再回退到 `getStatusTagType` 的前端兜底映射,不要直接绕开集中配置另写一份。
|
||||
|
||||
## 页面资源与菜单目录约束
|
||||
|
||||
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
||||
- `component` 决定“渲染哪个页面组件”;菜单目录决定“挂在哪个业务目录下”和最终 URL;页面资源主要用于从白名单中选择并回填组件信息。
|
||||
- 不要因为组件键是 `view.system_dict`,就推导它只能挂在 `/system/dict`;同一个页面组件允许挂在新的业务目录下复用。
|
||||
- 页面资源白名单中的标准路径是参考路径,不应反向覆盖当前菜单树已经确定的最终 URL。
|
||||
- 涉及菜单编辑器或页面资源选择逻辑时,优先保证“组件可解析、资源合法、最终 URL 由菜单树决定”,不要强绑页面资源标准路径和父级目录前缀。
|
||||
|
||||
## 代码约定
|
||||
|
||||
- 优先使用现有别名导入(`@/...`、`~/...`),避免过长的相对路径。
|
||||
- 保持与 TypeScript 严格模式兼容。
|
||||
- 后端返回的主键 ID、用户 ID、对象 ID、雪花 ID、Long ID 等,一律优先按 `string` 在前端接收和传递,不要默认写成 `number`。
|
||||
- 这是强约束,不是建议项。原因包括:JavaScript `number` 无法稳定承载长整型精度、接口序列化后可能出现精度丢失、运行时还容易出现 `number/string` 键不一致,最终导致回显、筛选、映射、路由参数、对象上下文等逻辑异常。
|
||||
- 这条约束要落实到所有层:`typings`、API 返回类型、页面表单 `model`、组件 `props` / `emits`、`ElSelect` 的 `value`、路由参数、查询参数、`Map` 键、筛选条件、store 状态,一律优先使用 `string` / `string[]`。
|
||||
- 明确禁止把 ID 当成普通数值处理。禁止写法包括但不限于:`Number(id)`、`+id`、`parseInt(id)`、`parseFloat(id)`、`Math.floor(id)`,以及任何“为了比较、传参、回填、提交而把 ID 转成 number”的做法。
|
||||
- 比较、映射、筛选 ID 时,默认按字符串语义处理,例如 `id === targetId`、`Map<string, T>`、`Set<string>`,不要混用 `number/string` 双口径。
|
||||
- 如果后端当前接口暂时还返回数值型 ID,前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
|
||||
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
|
||||
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
|
||||
- API 适配层兜底(实操约束):所有从后端接收的数值型 ID 字段(不论后端实际返回 `string`、`number` 或两者混合),都必须在 `src/service/api/*` 的 normalize 或 map 函数中显式调用 `String(rawId)` 归一一次;前端业务层(`views`、`store`、组件、`Map` 键、路由参数)只接收 `string` 形态,永远不需要自己 `String()`。这条与后端是否做了 Long → String 全局序列化无关——后端做了是双保险,没做且字段取值始终在 JS 安全整数内(例如 `infra_file_config.id` 永远是两位数)也是合理选择,前端 normalize 已经把口径收死,业务层无感。但这条不开按字段取值范围豁免的口子:前端 normalize 是无差别的,任何 ID 都要 `String()`,不要按某个字段当前取值大小决定要不要走 normalize,避免后续逐步污染仓库的 ID 纪律。
|
||||
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID,当前任务触达相关链路时优先顺手矫正”;不要继续复制历史写法。
|
||||
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||
- 修改界面时优先延续 `src/layouts` 和 `src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
|
||||
- 注释保持克制,只在代码本身不够直观时补充必要说明。
|
||||
|
||||
## 注释与编码
|
||||
|
||||
- 新增或修改代码时,关键分支、关键约束和非直观实现可以补充简洁中文注释。
|
||||
- 不要为了省事删除原有有效注释,也不要添加没有信息量的注释。
|
||||
- 写入中文内容时保持 UTF-8 编码,并自行确认显示正常;不要用改成英文来规避编码问题。
|
||||
|
||||
## 校验建议
|
||||
|
||||
对有实际影响的代码改动,优先执行:
|
||||
|
||||
- 对前端页面、交互、样式类任务,除非用户明确要求新增测试、运行测试,或当前任务本身就是修测试/补测试,否则默认不补前端测试,也不主动跑前端测试命令。
|
||||
- 上述前端任务默认只做静态校验;最小校验口径是 `pnpm typecheck`。如果需要更严格的静态检查,再补 `pnpm lint`。
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
|
||||
如果改动涉及路由,额外执行:
|
||||
|
||||
- `pnpm gen-route`
|
||||
|
||||
如果改动影响页面资源清单、菜单资源选择或页面白名单,额外执行:
|
||||
|
||||
- `pnpm gen:page-resource-manifest`
|
||||
|
||||
静态校验时,至少自查以下几点:
|
||||
|
||||
- 调用链是否闭环,改动是否落在正确的分层位置
|
||||
- 路由、菜单、权限标识、主题状态或资源注册是否前后一致
|
||||
- 改动范围是否控制在当前任务所需的最小集合内
|
||||
- 文档、类型定义、接口封装或生成产物是否需要同步更新
|
||||
|
||||
## 提交与脚本约束
|
||||
|
||||
- `pre-commit` 会执行 `pnpm typecheck && pnpm lint && git diff --exit-code`,因此“代码能跑”不等于“可以提交”。
|
||||
- `pnpm lint` 实际会执行 `eslint . --fix`;提交失败后要检查是否有被自动修复但尚未重新暂存的文件。
|
||||
- 提交规范说明以 `docs/前端提交规范与示例.md` 为准;最稳妥的提交方式是执行 `pnpm commit:zh`,按交互选择 `type`、`scope` 和 `description`。
|
||||
- `commit-msg` 钩子会校验 Conventional Commits;推荐使用 `pnpm commit:zh` 生成提交信息。
|
||||
- 如果手动提交,执行 `git commit -m "type(scope): 描述"`,并确保 `type`、`scope`、描述写法与 `docs/前端提交规范与示例.md` 保持一致。
|
||||
- 提交信息基础格式遵循 `type(scope): 描述`。
|
||||
- 写 Node ESM 脚本时,避免沿用 `__filename`、`__dirname` 这类下划线悬挂命名。
|
||||
- 能并发的批量异步任务优先 `Promise.all(...)`,不要默认在循环体里直接 `await`。
|
||||
- 手写 `new Promise(...)` 时优先使用 block 写法,不要把 executor 写成隐式返回值的单表达式箭头函数。
|
||||
- 一个函数如果开始同时承担“判断 + 转换 + 组装 + 递归”,优先拆 helper,避免把复杂度堆到单个函数里。
|
||||
|
||||
## 代理工作说明
|
||||
|
||||
- 除非用户明确要求,否则不要主动执行任何 git 操作,包括但不限于 `git status`、`git diff`、`git add`、`git commit`、`git restore`、`git reset`、`git checkout`。
|
||||
- 如果任务需要识别用户已有改动,优先通过当前文件内容和直接读取文件来判断;只有用户明确要求查看 git 状态时,才执行对应 git 命令。
|
||||
- 在工作树不干净时,不要回退与当前任务无关的变更。
|
||||
- 修改布局或主题行为时,同时检查 `src/layouts/*` 和 `src/store/modules/theme/*`,因为相关逻辑分散在界面层和状态层。
|
||||
- 修改路由或菜单时,同时检查 `build/plugins/router.ts` 和 `src/router/routes/*`。
|
||||
- 做架构级、权限级或页面规范级修改前,优先查阅 `docs/` 中现有说明,避免与当前文档约定冲突。
|
||||
431
CLAUDE.md
Normal file
431
CLAUDE.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件是我(Claude)在 `cn-rdms-web` 项目中的个人工作笔记,沉淀团队既有规范(来源:`AGENTS.md`)与协作惯例。每次进入仓库前先读这一份,避免重复踩坑。
|
||||
|
||||
> 本文件仅本地保留,已加入 `.gitignore`,请勿提交。
|
||||
|
||||
---
|
||||
|
||||
## 0. 行为基线(最重要,先记住)
|
||||
|
||||
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
|
||||
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
|
||||
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
|
||||
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
|
||||
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
|
||||
- **不主动执行 git 操作**(status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
|
||||
- 工作树脏的时候,**不要回退与当前任务无关的变更**。
|
||||
- 静态校验默认只跑 `pnpm typecheck`;UI/交互/样式类任务**默认不补也不跑前端测试**,除非用户明确要求。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目骨架(认知地图)
|
||||
|
||||
| 维度 | 现状 |
|
||||
|---|---|
|
||||
| 应用 | RDMS 系统的 Vue 3 后台前端 |
|
||||
| 包管理 | `pnpm`(>=8.7.0),Node `>=20.19.0` |
|
||||
| 工具链 | Vite 7、TypeScript、Pinia、Element Plus、UnoCSS |
|
||||
| 工作区 | `packages/*`,通过 `@sa/*` 引用 |
|
||||
| 别名 | `@` → `src`;`~` → 仓库根 |
|
||||
| 端口 | dev 9527 / preview 9725 |
|
||||
| 环境文件 | `.env`、`.env.dev`、`.env.prod` |
|
||||
|
||||
**已经形成闭环的五条主线,后续改动顺着做,不平行起新的:**
|
||||
|
||||
1. **路由来源统一**:页面文件 + 自定义路由 → `elegant-router` 生成 → `build/plugins/router.ts` 集中补 `meta`。
|
||||
2. **权限入口统一**:常量路由 / 权限路由分流;`route store` 负责初始化、菜单生成、缓存路由、面包屑。
|
||||
3. **请求入口统一**:所有业务请求走 `src/service/request/index.ts`。
|
||||
4. **页面套路统一**:列表页 = 搜索区 + 表格区 + 操作弹层/抽屉 + `modules/*` 子组件。
|
||||
5. **衍生资产统一**:页面资源白名单从路由结构生成,不手工维护第二份。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键目录速查
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| `src/views` | 业务页面(编排层薄) |
|
||||
| `src/components` | 共享组件 |
|
||||
| `src/layouts` | 应用壳、头部、侧栏、菜单、标签页、主题抽屉 |
|
||||
| `src/store/modules` | Pinia 模块:app / auth / route / tab / theme / dict |
|
||||
| `src/service/api` | 接口封装、参数归一化、查询字符串拼装、返回类型对齐 |
|
||||
| `src/service/request` | 统一请求实例、鉴权、加密、错误处理、token 刷新 |
|
||||
| `src/router/routes` | 自定义路由 |
|
||||
| `src/router/elegant` | **生成产物,不要手改** |
|
||||
| `src/theme/settings.ts` | 默认主题与布局设置 |
|
||||
| `build/plugins/router.ts` | elegant-router 配置 + 路由 meta 生成 |
|
||||
| `src/hooks/common/table.ts` | 列表页表格 hook 主入口 |
|
||||
| `src/hooks/common/form.ts` | 表单校验与表单实例 hook |
|
||||
| `src/constants/status-tag.ts` | 业务对象状态颜色(ElTag type)集中配置 |
|
||||
| `src/styles/scss/element-plus.scss` | 表格/弹层/按钮/表单 密度与公共壳样式 |
|
||||
| `packages/*` | 项目内本地共享库 |
|
||||
| `docs/` | 架构/权限/页面规范文档,做相关改动前先查 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 生成文件(不要手改)
|
||||
|
||||
- `src/router/elegant/imports.ts`
|
||||
- `src/router/elegant/routes.ts`
|
||||
- `src/router/elegant/transform.ts`
|
||||
- `src/typings/elegant-router.d.ts`
|
||||
- `src/typings/components.d.ts`
|
||||
- `docs/frontend-page-resource-manifest.json`
|
||||
|
||||
**再生命令:**
|
||||
- 路由产物过期 → `pnpm gen-route`
|
||||
- 页面资源清单需同步 → `pnpm gen:page-resource-manifest`
|
||||
|
||||
---
|
||||
|
||||
## 4. 路由与导航
|
||||
|
||||
- 新增业务页:通过页面文件 + `build/plugins/router.ts` 补齐,**不要在多个位置重复注册**。
|
||||
- `meta.icon` = Iconify 图标;`meta.localIcon` = 本地 SVG。**不要混用字段语义。**
|
||||
- `meta` 中心落点是 `build/plugins/router.ts`,新页的 `icon`/`order`/`roles`/`keepAlive` 在那里集中维护。
|
||||
- `meta.constant = true` → 常量路由;其他默认权限路由。常量路由维护入口是 `build/plugins/router.ts` 和 `src/router/routes/custom-routes.ts`。
|
||||
- `i18nKey` 是兼容字段,不是新页必须补齐项。
|
||||
|
||||
### 4.1 对象上下文业务域(重要陷阱)
|
||||
|
||||
- `product`、`project` 这类业务域,**入口页是设计如此**:先进业务域入口页 → 再选对象建上下文。**不要把"入口页是可点击菜单"误判成 bug。**
|
||||
- 入口页(如 `product_list -> /product/list -> view.product_list`)可作为左侧一级菜单实际命中页。这 ≠ 已进入对象上下文态。
|
||||
- **遇到"点入口页后布局壳消失、只剩内容页"**:先查是否动态权限路由模式 + 后端 `get-user-routes` 是否缺业务域根路由。**不要直接把入口菜单从"菜单"改成"目录"**。
|
||||
- 在 `VITE_AUTH_ROUTE_MODE=dynamic` 下,若后端只返回叶子页(如缺 `product -> layout.base`,只返 `product_list`),前端必须在动态路由归一化阶段**补回本地业务域骨架**,不能让入口裸挂为顶层 `view.*`。
|
||||
- 对象上下文稳定来源仍是本地路由骨架;动态路由兼容只能"补骨架 + 对齐入口",不能反推。
|
||||
- 新增业务域时同步检查:本地静态骨架、`src/constants/object-context.ts` 中的 `domainKey/entryRouteKey/entryRoutePath/fallbackDefaultRouteKey`、动态路由归一化、对象上下文 store、头部菜单切换。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分层职责
|
||||
|
||||
| 层 | 该做 | 不该做 |
|
||||
|---|---|---|
|
||||
| `src/views` | 编排状态、表单行为、组合 store/service | 散落 URL 拼接、token 注入、错误提示、权限路由推导 |
|
||||
| `src/components` | 可复用 UI / 局部业务部件 | 长期堆只服务单页面的复杂流程 |
|
||||
| `src/service/api` | 接口封装、参数归一化、查询拼装、类型对齐 | 在 views/store/components 重复手写接口地址和序列化 |
|
||||
| `src/service/request` | 统一鉴权/加密/成功码/token 刷新/错误处理 | 平行引入新的 axios/fetch 链绕开封装 |
|
||||
| `src/store/modules` | 跨页面共享状态 | 把临时局部状态堆进全局 store |
|
||||
| `src/router` & `build/plugins/router.ts` | 路由/菜单/权限标识/首页/路由 meta | 在页面里临时写条件分支替代正式配置 |
|
||||
| `src/layouts` & `src/theme` | 全局布局壳与主题 | 在业务页面复制平行布局/主题状态 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 业务页面开发风格
|
||||
|
||||
- **页面组件保持"编排层薄"**:页面文件主管搜索参数、表格 hook、列定义、弹层开关、接口编排。
|
||||
- 列表页拆同目录 `modules/*`:搜索组件、操作弹层、详情抽屉、资源面板等。
|
||||
- **参考实现**:系统管理下 `user`/`role`/`menu`/`dict`。
|
||||
- 列表 hook 优先复用:`src/hooks/common/table.ts` 的 `useUIPaginatedTable`、`useTableOperate`、`defaultTransform`。
|
||||
- 表单 hook 优先复用:`src/hooks/common/form.ts` 的 `useForm`、`useFormRules`。
|
||||
- **业务口径是"内网中文优先"**:新页不必强行国际化;但已有大量 `$t(...)` 的页面继续开发时,保持局部一致,不要中文/i18n 混用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 表格、搜索区、操作列
|
||||
|
||||
### 7.1 搜索区(强约束)
|
||||
|
||||
- **必须用** `src/components/custom/table-search-fields.vue` 的 `fields` 声明式配置,不得手写 `ElRow/ElCol/ElFormItem` 骨架。
|
||||
- 仅当字段存在复杂联动、自定义插槽或 `TableSearchFields` 明确无法承载时,才退回 `src/components/custom/table-search-panel.vue`,并在实施说明中写明原因。
|
||||
- **搜索区按钮组固定在第一行最后一格**;存在折叠时按钮顺序固定为 **展开/收起 → 重置 → 查询**。**不允许**因查询条件不足、展开收起或响应式样式把按钮提前或挤到下一行。
|
||||
- `columns` 表示首行总格数,**最后 1 格永远留给按钮**;字段不足 `columns - 1` 由组件补空占位;超过则进入展开区。
|
||||
- 4 个查询条件的场景必须 `:columns="4"`(3 条件 + 按钮)。
|
||||
- 搜索模块只接 `model` 和必要选项,只发 `reset`/`search`,**不直接承载列表请求**。
|
||||
- 详细规范见 `docs/table-search-fields-usage.md`。
|
||||
|
||||
### 7.2 表格
|
||||
|
||||
- 操作列优先复用 `src/components/custom/business-table-action-cell.tsx`。
|
||||
- 操作数 ≤ 2:直出;操作数 > 2:**1 个直出主按钮 + 1 个更多按钮**。
|
||||
- `ElCard` 承载 `ElTable height="100%"` 时,`body-class` 优先用公共类 **`business-table-card-body`**(由 `src/styles/scss/element-plus.scss` 维护)。**不要为每页新建 `xxx-table-card-body` 私有样式**。历史私有类不强制专项回改,触达再收敛。
|
||||
- 表格/按钮/弹层/表单的尺寸与间距标准走 `element-plus.scss` 和公共组件,**不要在业务页散落写局部尺寸作为事实标准**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 表单与弹层(强约束)
|
||||
|
||||
### 8.1 组件选择
|
||||
|
||||
- 标准组合:`ElDialog / ElDrawer / ElForm / ElScrollbar / #footer`。
|
||||
- 轻中量表单:`src/components/custom/business-form-dialog.vue`。
|
||||
- 字段较多 / 需保留列表上下文 / 重型控件:`src/components/custom/business-form-drawer.vue`。
|
||||
- 表单分组:`src/components/custom/business-form-section.vue`。
|
||||
|
||||
### 8.2 Dialog 宽度三档(按纯表单字段数)
|
||||
|
||||
| 字段数 | preset | 默认列数 | 目标宽度 |
|
||||
|---|---|---|---|
|
||||
| ≤ 6 | `sm` | 单列 | 520px |
|
||||
| 7 ~ 14 | `md` | 双列 | 720px |
|
||||
| > 14 | `lg` | 双列为主 | 960px |
|
||||
|
||||
- 实际宽度上限:`calc(100vw - 32px)`。
|
||||
- **不因为单个 textarea 自动升档**,不做列数响应式折叠。
|
||||
- 归到 `sm` 时不能只改 preset,**字段布局也要落到单列**:常规 `ElCol` 用 `span=24`,除非已判定为复合内容特例。
|
||||
|
||||
### 8.3 复合内容特例
|
||||
|
||||
左右分栏 / 表单+表格 / 表单+树 / 关系编辑器 / 时间线 / 大段说明区 → 不强按字段数归类,按内容复杂度评估 `md`/`lg` 或更宽。**只有无法合理归入"纯表单三档"时才允许特例。**
|
||||
|
||||
### 8.4 表单布局
|
||||
|
||||
- 常规 CRUD:`label-position="top"` + `ElRow + ElCol` 双列 + `gutter=16`。
|
||||
- 普通字段 `span=12`;长文本/重量级字段 `span=24`。
|
||||
- 字段 ≤ 6 默认按单列理解。
|
||||
|
||||
### 8.5 其他
|
||||
|
||||
- **禁止**用页面级宽范围样式覆盖整页 `.business-form-dialog` 来统一放大;如需特殊宽度,必须精确作用于目标弹框,不误伤同页其他 dialog。
|
||||
- 底部按钮固定 **取消 → 确认**,右对齐。
|
||||
- 单选组/开关字段优先复用既有钩子:`business-form-radio-group`、`business-form-switch-field`。
|
||||
- **权限按钮默认"无权限不渲染"**;只有业务状态暂时不可操作但仍需让用户感知入口存在时,才允许保留禁用态。
|
||||
|
||||
### 8.6 全局反馈(Toast / Message)
|
||||
|
||||
- **全局反馈通道只有一个**:`window.$message`(`src/components/common/app-provider.vue` 注入的 `ElMessage`),全仓 30+ 处都用它。**不要平行引入 `ElNotification` / 自定义 toast**;要求"全局风格切换"则单独立项,不要在小改动里悄悄启动。
|
||||
- **type 语义**(4 种 type → 3 类视觉语义):
|
||||
- `error` → 错误(红):操作失败、明确异常
|
||||
- `warning` → 告警(橙):用户即将出错、风险确认
|
||||
- `success` → 通知-成功(绿):操作成功
|
||||
- `info` → 通知-信息(蓝):信息告知、默认兜底说明
|
||||
- **type 选错就丑**:`warning` 是"出错警告",不要拿来表达普通信息(用 `info`);`info` 是"信息告知",不要拿来报错(用 `error`)。
|
||||
- **"先做 A 再做 B" 的引导性提示**:用 `ElFormItem :error="msg"` 红字内联(跟校验同款),**不要用 toast**——toast 适合事后反馈、不阻断流程,对引导性提示体验差。
|
||||
- **全局视觉**(实色背景 + 白字 + 阴影 + `$radius` 圆角)由 `src/styles/scss/element-plus.scss` 末尾的 `.el-message` 块统一维护,**业务页面禁止覆盖** `.el-message-*` 样式。要调颜色就改 `element-plus.scss`,不要在业务页 scoped 散落。
|
||||
|
||||
```ts
|
||||
window.$message?.success('保存成功');
|
||||
window.$message?.error('保存失败:xxx');
|
||||
window.$message?.warning('当前修改未保存,确认离开?');
|
||||
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 接口、路由、权限
|
||||
|
||||
- 默认走 `src/service/request/index.ts`,不另造鉴权/加密/错误处理/token 刷新。
|
||||
- 接口前缀、服务常量优先复用 `src/constants/service.ts`。
|
||||
- 后端契约变化时同步检查 `src/service/api/*`、`src/typings/api/*`、相关页面、说明文档。
|
||||
- 路由/菜单/权限改动时同步检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*`、相关文档。
|
||||
- 路由产物过期:改源配置 + `pnpm gen-route`,**不要把手工修补生成文件当常规方案**。
|
||||
|
||||
---
|
||||
|
||||
## 10. 运行时字典
|
||||
|
||||
- 由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化。**不要在页面重复直调字典接口。**
|
||||
- 字典编码常量收敛在 `src/constants/dict.ts`。**不要散落硬编码 `dictType`。**
|
||||
- **不要猜字典编码**:先从后端接口文档/字段契约/系统字典管理页确认真实 `dictType`,再写入常量。
|
||||
- 常量加中文注释:对应业务字段 + 编码确认来源。
|
||||
- 后端编码带历史命名痕迹(如 `rdms_product_direction`)时,前端常量名按真实业务语义命名,**不扩散历史误导**。
|
||||
|
||||
### 字典使用方式
|
||||
|
||||
| 场景 | 组件/Hook |
|
||||
|---|---|
|
||||
| 表单下拉 | `src/components/custom/dict-select.vue` |
|
||||
| 普通文案回显 | `src/components/custom/dict-text.vue` |
|
||||
| 标签态回显 | `src/components/custom/dict-tag.vue`(标签颜色业务页自决) |
|
||||
| script setup / TSX 列格式化 / 复杂判断 | `src/hooks/business/dict.ts` 的 `useDict(dictCode)` |
|
||||
|
||||
`useDict` 常用能力:`dictOptions`、`getItem`、`getLabel`、`getLabels`、`hasValue`。
|
||||
|
||||
`DictSelect` 默认只展示启用项;需包含禁用项显式 `:only-enabled="false"`。
|
||||
|
||||
```vue
|
||||
<DictSelect v-model="form.directionCode" :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" />
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="row.directionCode" />
|
||||
<DictTag :dict-code="SYSTEM_USER_COMPANY_DICT_CODE" :value="row.companyCode" type="info" />
|
||||
```
|
||||
|
||||
```ts
|
||||
const { getLabel, getLabels } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const directionLabel = getLabel(row.directionCode);
|
||||
const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 页面资源 & 菜单目录(三层不同概念)
|
||||
|
||||
- `component` = 渲染哪个页面组件
|
||||
- 菜单目录 = 挂在哪个业务目录、最终 URL
|
||||
- 页面资源 = 白名单中选择并回填组件信息
|
||||
|
||||
**不要混淆**:组件键 `view.system_dict` ≠ 必须挂 `/system/dict`,同一个组件允许在新业务目录下复用。
|
||||
|
||||
页面资源白名单的标准路径是**参考路径,不应反向覆盖菜单树已确定的最终 URL**。
|
||||
|
||||
菜单编辑器/页面资源选择逻辑改动时,保证"组件可解析、资源合法、最终 URL 由菜单树决定",**不要强绑标准路径与父目录前缀**。
|
||||
|
||||
---
|
||||
|
||||
## 12. ID 类型铁律(强约束,必须严格执行)
|
||||
|
||||
> 后端主键 ID / 用户 ID / 对象 ID / 雪花 ID / Long ID **一律按 `string` 接收和传递**。
|
||||
|
||||
**原因**:JS `number` 无法稳定承载 Long 精度;序列化精度丢失;`number/string` 键不一致 → 回显/筛选/映射/路由参数/对象上下文异常。
|
||||
|
||||
### 落实范围(全部)
|
||||
`typings`、API 返回类型、表单 model、组件 props/emits、`ElSelect` 的 value、路由参数、查询参数、`Map` 键、筛选条件、store 状态 → **全部 `string` / `string[]`**。
|
||||
|
||||
### 禁止写法
|
||||
- ❌ `Number(id)` / `+id` / `parseInt(id)` / `parseFloat(id)` / `Math.floor(id)`
|
||||
- ❌ 任何"为了比较/传参/回填/提交而把 ID 转 number"
|
||||
|
||||
### 比较与映射
|
||||
- ✅ `id === targetId`
|
||||
- ✅ `Map<string, T>` / `Set<string>`
|
||||
- ❌ 不混用 `number/string` 双口径
|
||||
|
||||
### 后端契约风险(关键)
|
||||
- 后端暂返数值型 ID 时,**前端在 `typings` / API 适配层 / 进业务层前转 `string`**,不要按 `number` 扩散。
|
||||
- **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。
|
||||
- 最稳妥契约:**后端 Long ID 直接按字符串返回**;前端全链路按字符串。后端未改,前端也不得新增 `number` 口径 ID。
|
||||
|
||||
### API 适配层兜底(操作约束)
|
||||
- 所有从后端接收的数值型 ID 字段,**必须**在 `src/service/api/*` 的 normalize/map 函数里显式 `String(rawId)` 一次——**不管后端返回 string、number、还是混合**。
|
||||
- 业务层(views / store / 组件 / `Map` key / 路由参数)**只接收 string**,从不需要自己 `String()`。
|
||||
- 与"后端是否已经全局 Long → String"**无关**:
|
||||
- 后端做了 → 双保险
|
||||
- 后端没做但取值在 JS 安全整数内 → 单层防御也对(实际值不丢精度)
|
||||
- 后端没做且取值超安全整数 → 不安全,必须推后端改
|
||||
- **不开"按取值范围豁免"的口子**:哪怕后端说"这个字段永远是两位数"(如 `infra_file_config.id`),前端照样 `String()`。否则后续会冒出"projectStatus 是 Long 但只有 0-99,也可以保留 number"等连锁例外,铁律字面被掏空。
|
||||
|
||||
### 历史代码原则
|
||||
不再新增 `number` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。
|
||||
|
||||
---
|
||||
|
||||
## 13. 代码约定
|
||||
|
||||
- 优先用别名导入(`@/...`、`~/...`),避免长相对路径。
|
||||
- 与 TypeScript 严格模式兼容。
|
||||
- 沿用 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||
- UI 沿用 `src/layouts` 和 `src/theme` 现有模式,不平行引入新设计体系。
|
||||
- **注释克制**:只在代码本身不直观时补必要中文说明;不删原有有效注释;不写没信息量的注释。
|
||||
- 中文内容用 UTF-8,自检显示;**不要用改成英文规避编码问题**。
|
||||
- Node ESM 脚本:避免 `__filename`/`__dirname` 这类下划线悬挂命名。
|
||||
- 批量异步并发优先 `Promise.all(...)`,不在循环里默认 `await`。
|
||||
- 手写 `new Promise(...)` 用 block 写法,不要写成隐式返回的单表达式箭头函数。
|
||||
- 函数若同时承担"判断 + 转换 + 组装 + 递归",拆 helper。
|
||||
|
||||
---
|
||||
|
||||
## 14. 校验
|
||||
|
||||
### 14.1 校验口径
|
||||
|
||||
| 任务类型 | 默认校验 |
|
||||
|---|---|
|
||||
| 前端页面/交互/样式 | `pnpm typecheck`,不主动跑测试 |
|
||||
| 需更严格静态检查 | 加 `pnpm lint` |
|
||||
| 涉及路由 | 加 `pnpm gen-route` |
|
||||
| 影响页面资源清单/菜单资源选择/页面白名单 | 加 `pnpm gen:page-resource-manifest` |
|
||||
|
||||
### 14.2 静态校验自查清单
|
||||
- 调用链是否闭环?改动是否在正确分层?
|
||||
- 路由/菜单/权限标识/主题状态/资源注册 是否前后一致?
|
||||
- 改动范围是否控制在最小集合?
|
||||
- 文档/类型/接口封装/生成产物 是否需要同步更新?
|
||||
|
||||
---
|
||||
|
||||
## 15. 提交规范
|
||||
|
||||
- **`pre-commit` 执行 `pnpm typecheck && pnpm lint && git diff --exit-code`**:能跑 ≠ 能提交。
|
||||
- `pnpm lint` 会跑 `eslint . --fix`:提交失败后检查是否有被自动修复但未重新暂存的文件。
|
||||
- 推荐提交方式:`pnpm commit:zh`(交互选 type/scope/description)。
|
||||
- 手动提交:`git commit -m "type(scope): 描述"`,参考 `docs/前端提交规范与示例.md`。
|
||||
- `commit-msg` 钩子校验 Conventional Commits。
|
||||
|
||||
---
|
||||
|
||||
## 16. 协作记忆(与本仓库用户共事)
|
||||
|
||||
- 用户语言:**中文**(始终用中文回复)。
|
||||
- **不主动跑 git 命令**(用户已强调)。
|
||||
- 默认精简、结论先行。
|
||||
- 工作树脏时不要回退无关变更。
|
||||
- 改架构/权限/页面规范前先翻 `docs/`,避免与现有约定冲突。
|
||||
- 改布局/主题时同时检查 `src/layouts/*` 与 `src/store/modules/theme/*`。
|
||||
- 改路由/菜单时同时检查 `build/plugins/router.ts` 与 `src/router/routes/*`。
|
||||
|
||||
---
|
||||
|
||||
## 17. 常用命令速查
|
||||
|
||||
```bash
|
||||
pnpm typecheck # 最小静态校验
|
||||
pnpm lint # eslint . --fix
|
||||
pnpm gen-route # 重新生成路由产物
|
||||
pnpm gen:page-resource-manifest # 同步页面资源清单
|
||||
pnpm commit:zh # 交互式提交(推荐)
|
||||
pnpm dev # dev server (9527)
|
||||
pnpm preview # preview server (9725)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. 业务对象状态颜色
|
||||
|
||||
- 集中文件:`src/constants/status-tag.ts`
|
||||
- 各业务域 `statusCode → ElTag type` 在此统一维护,**不要在各页面散落硬编码**。
|
||||
- 已支持域:`projectExecution`、`projectTask`;预留:`project`、`product`、`requirement`、`workOrder`。
|
||||
- helper:`getStatusTagType(domain, statusCode)`,未匹配回退 `'info'`。
|
||||
- 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)`。
|
||||
- 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map;调用方写一个 wrapper 即可。
|
||||
- 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 helper(前端兜底)。
|
||||
|
||||
---
|
||||
|
||||
## 19. 防重复提交(两层联防,强约束)
|
||||
|
||||
> 用户双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮无内置 loading 都会让同一写操作发多次。两层防御缺一不可。
|
||||
|
||||
### 两层各自的职责
|
||||
|
||||
| 层 | 谁负责 | 行为 |
|
||||
|---|---|---|
|
||||
| 视觉层 | `business-form-dialog.vue` / `business-form-drawer.vue` | submit 触发后立即把"确认"按钮置 loading + disabled,挡住二次点击 |
|
||||
| 逻辑层(兜底) | `src/service/request/dedupe.ts`(已通过 `withDedupe` 包住 `request` 实例) | 写操作 pending 期内复用同一 Promise,不真正发出第二次请求 |
|
||||
|
||||
### 业务侧关注点
|
||||
|
||||
- **不要裸手写** `<ElButton @click="submit">` 调接口;用 `business-form-dialog` / `business-form-drawer` 包;非要用裸 `ElButton` 时**必须**自行绑 `:loading` 并在 await 期间锁住。
|
||||
- **`ElMessageBox.confirm` 的"确定"按钮没 loading 能力**——不要尝试改它,靠第二层兜底就够。
|
||||
- **新接口默认享受去重**,调用方零改动;不要在 `src/service/api/*` 或页面层再造一套去重。
|
||||
|
||||
### 去重生效边界
|
||||
|
||||
- 自动去重:`POST` / `PUT` / `DELETE` / `PATCH`。
|
||||
- 不去重:`GET` / `HEAD` / `OPTIONS`(避免误伤分页 / 多 widget 并发查询);请求体为 `FormData` / `Blob`(上传场景)。
|
||||
- 单接口逃生口:`request({ ..., dedupe: false })`——极少用,仅当业务真允许短时间内连发完全相同的写请求。
|
||||
- 兜底超时 30s:保险丝,防止 Promise 永不 settle 时内存泄漏。
|
||||
|
||||
### 指纹算法
|
||||
|
||||
`method 大写 | URL + 排序后的 params 序列化 | 稳定序列化的 body`。body 内对象按 key 排序、数组保序——保证调用顺序不同但参数等价的两次请求拿到同一指纹。
|
||||
|
||||
### 何时回到本节查
|
||||
|
||||
- 新建写操作页面 → 视觉层用对组件、不裸 `ElButton` 调接口
|
||||
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
||||
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
||||
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
|
||||
|
||||
---
|
||||
|
||||
## 20. 我生成文档的输出格式(强约束)
|
||||
|
||||
- **superpowers 工作流(`docs/superpowers/plans/`、`docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
|
||||
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html`、`技术负债台账.html`)的样式骨架:
|
||||
- 单文件、内联 CSS
|
||||
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
|
||||
- 14px / `line-height: 1.7`、`PingFang SC` / `Microsoft YaHei` 中文字体优先
|
||||
- 模块化区块:`section` + 编号 h2、`card`、`table.cmp`、`pre`、`tag-ok/warn/bad/crit`
|
||||
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
|
||||
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
|
||||
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。
|
||||
35
README.md
35
README.md
@@ -1,35 +0,0 @@
|
||||
# cn-rdms-web
|
||||
|
||||
这是当前项目的前端工程仓库。
|
||||
|
||||
原开源模板项目的介绍内容已移除,这个 README 现在只保留当前项目自身所需的信息。
|
||||
|
||||
## 项目说明
|
||||
|
||||
待补充。
|
||||
|
||||
建议后续在这里补充:
|
||||
|
||||
- 项目背景
|
||||
- 技术栈
|
||||
- 目录结构
|
||||
- 本地启动方式
|
||||
- 环境变量说明
|
||||
- 构建与发布流程
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm build
|
||||
pnpm build:dev
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProxyOptions } from 'vite';
|
||||
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
|
||||
import { consola } from 'consola';
|
||||
import { WEB_SERVICE_PREFIX } from '../../src/constants/service';
|
||||
import { createServiceConfig } from '../../src/utils/service';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,14 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
||||
Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
|
||||
});
|
||||
|
||||
// 富文本图片 <img src="/admin-api/system/file/{configId}/get/{path}"> 由浏览器直接发起,
|
||||
// 不经过 axios,没有 baseURL 前缀。这里加一条原样透传,避免被 Vite SPA fallback 兜底成 index.html。
|
||||
// 不带 rewrite —— 原样把 /admin-api/* 转发到后端;不影响现有 /proxy-default 链路。
|
||||
proxy[WEB_SERVICE_PREFIX] = {
|
||||
target: baseURL,
|
||||
changeOrigin: true
|
||||
};
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,135 @@ export function setupElegantRouter() {
|
||||
onRouteMetaGen(routeName) {
|
||||
const key = routeName as RouteKey;
|
||||
|
||||
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
|
||||
const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench'];
|
||||
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
|
||||
workbench: {
|
||||
icon: 'mdi:view-dashboard-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
product: {
|
||||
icon: 'carbon:product',
|
||||
order: 4
|
||||
},
|
||||
product_list: {
|
||||
icon: 'material-symbols:view-list-outline-rounded',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
product_dashboard: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
},
|
||||
product_requirement: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
},
|
||||
product_setting: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
},
|
||||
project: {
|
||||
icon: 'mdi:briefcase-outline',
|
||||
order: 5
|
||||
},
|
||||
project_list: {
|
||||
icon: 'material-symbols:view-list-outline-rounded',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
project_project: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
project_project_overview: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
project_project_requirement: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
project_project_execution: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
project_project_setting: {
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
ticket: {
|
||||
icon: 'mdi:ticket-confirmation-outline',
|
||||
order: 6
|
||||
},
|
||||
'ticket_my-submitted': {
|
||||
icon: 'mdi:upload-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'ticket_my-pending': {
|
||||
icon: 'mdi:inbox-arrow-down-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
},
|
||||
metrics: {
|
||||
icon: 'mdi:chart-line',
|
||||
order: 7
|
||||
},
|
||||
'metrics_project-progress': {
|
||||
icon: 'mdi:progress-clock',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'metrics_member-efficiency': {
|
||||
icon: 'mdi:account-multiple-check-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
},
|
||||
metrics_worktime: {
|
||||
icon: 'mdi:clock-time-five-outline',
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center': {
|
||||
icon: 'mdi:account-circle-outline',
|
||||
order: 8
|
||||
},
|
||||
'personal-center_my-profile': {
|
||||
icon: 'mdi:account-box-outline',
|
||||
order: 0,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-item': {
|
||||
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-weekly': {
|
||||
icon: 'mdi:calendar-week-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-monthly': {
|
||||
icon: 'mdi:calendar-month-outline',
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-performance': {
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 4,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_my-application': {
|
||||
icon: 'mdi:file-document-outline',
|
||||
order: 5,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_pending-approval': {
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 6,
|
||||
keepAlive: true
|
||||
},
|
||||
system: {
|
||||
icon: 'carbon:cloud-service-management',
|
||||
order: 9,
|
||||
@@ -60,6 +187,20 @@ export function setupElegantRouter() {
|
||||
hideInMenu: true,
|
||||
roles: ['R_ADMIN'],
|
||||
activeMenu: 'system_user'
|
||||
},
|
||||
infra: {
|
||||
icon: 'ep:monitor',
|
||||
order: 20
|
||||
},
|
||||
'infra_state-machine': {
|
||||
icon: 'mdi:state-machine',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
},
|
||||
'infra_rd-code': {
|
||||
icon: 'mdi:identifier',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,475 @@
|
||||
{
|
||||
"generatedAt": "2026-03-27T05:39:32.467Z",
|
||||
"generatedAt": "2026-05-19T07:08:28.081Z",
|
||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
"pageComponentPattern": "view.<routeName>",
|
||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||
},
|
||||
"total": 5,
|
||||
"total": 22,
|
||||
"items": [
|
||||
{
|
||||
"name": "product_list",
|
||||
"path": "/product/list",
|
||||
"component": "view.product_list",
|
||||
"title": "产品列表",
|
||||
"routeTitle": "product_list",
|
||||
"i18nKey": "route.product_list",
|
||||
"icon": "material-symbols:view-list-outline-rounded",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "产品列表",
|
||||
"i18nKey": "route.product_list",
|
||||
"icon": "material-symbols:view-list-outline-rounded",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "product",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "project_list",
|
||||
"path": "/project/list",
|
||||
"component": "view.project_list",
|
||||
"title": "项目列表",
|
||||
"routeTitle": "project_list",
|
||||
"i18nKey": "route.project_list",
|
||||
"icon": "material-symbols:view-list-outline-rounded",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "项目列表",
|
||||
"i18nKey": "route.project_list",
|
||||
"icon": "material-symbols:view-list-outline-rounded",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "project",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "ticket_my-submitted",
|
||||
"path": "/ticket/my-submitted",
|
||||
"component": "view.ticket_my-submitted",
|
||||
"title": "我提交的工单",
|
||||
"routeTitle": "ticket_my-submitted",
|
||||
"i18nKey": "route.ticket_my-submitted",
|
||||
"icon": "mdi:upload-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我提交的工单",
|
||||
"i18nKey": "route.ticket_my-submitted",
|
||||
"icon": "mdi:upload-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "ticket",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "ticket_my-pending",
|
||||
"path": "/ticket/my-pending",
|
||||
"component": "view.ticket_my-pending",
|
||||
"title": "待我处理的工单",
|
||||
"routeTitle": "ticket_my-pending",
|
||||
"i18nKey": "route.ticket_my-pending",
|
||||
"icon": "mdi:inbox-arrow-down-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "待我处理的工单",
|
||||
"i18nKey": "route.ticket_my-pending",
|
||||
"icon": "mdi:inbox-arrow-down-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "ticket",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "metrics_project-progress",
|
||||
"path": "/metrics/project-progress",
|
||||
"component": "view.metrics_project-progress",
|
||||
"title": "项目进度",
|
||||
"routeTitle": "metrics_project-progress",
|
||||
"i18nKey": "route.metrics_project-progress",
|
||||
"icon": "mdi:progress-clock",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "项目进度",
|
||||
"i18nKey": "route.metrics_project-progress",
|
||||
"icon": "mdi:progress-clock",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "metrics",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "metrics_member-efficiency",
|
||||
"path": "/metrics/member-efficiency",
|
||||
"component": "view.metrics_member-efficiency",
|
||||
"title": "员工能效",
|
||||
"routeTitle": "metrics_member-efficiency",
|
||||
"i18nKey": "route.metrics_member-efficiency",
|
||||
"icon": "mdi:account-multiple-check-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "员工能效",
|
||||
"i18nKey": "route.metrics_member-efficiency",
|
||||
"icon": "mdi:account-multiple-check-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "metrics",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "metrics_worktime",
|
||||
"path": "/metrics/worktime",
|
||||
"component": "view.metrics_worktime",
|
||||
"title": "工时统计",
|
||||
"routeTitle": "metrics_worktime",
|
||||
"i18nKey": "route.metrics_worktime",
|
||||
"icon": "mdi:clock-time-five-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "工时统计",
|
||||
"i18nKey": "route.metrics_worktime",
|
||||
"icon": "mdi:clock-time-five-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "metrics",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-profile",
|
||||
"path": "/personal-center/my-profile",
|
||||
"component": "view.personal-center_my-profile",
|
||||
"title": "个人信息",
|
||||
"routeTitle": "personal-center_my-profile",
|
||||
"i18nKey": "route.personal-center_my-profile",
|
||||
"icon": "mdi:account-box-outline",
|
||||
"localIcon": null,
|
||||
"order": 0,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "个人信息",
|
||||
"i18nKey": "route.personal-center_my-profile",
|
||||
"icon": "mdi:account-box-outline",
|
||||
"localIcon": null,
|
||||
"order": 0,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-item",
|
||||
"path": "/personal-center/my-item",
|
||||
"component": "view.personal-center_my-item",
|
||||
"title": "我的事项",
|
||||
"routeTitle": "personal-center_my-item",
|
||||
"i18nKey": "route.personal-center_my-item",
|
||||
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的事项",
|
||||
"i18nKey": "route.personal-center_my-item",
|
||||
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-weekly",
|
||||
"path": "/personal-center/my-weekly",
|
||||
"component": "view.personal-center_my-weekly",
|
||||
"title": "我的周报",
|
||||
"routeTitle": "personal-center_my-weekly",
|
||||
"i18nKey": "route.personal-center_my-weekly",
|
||||
"icon": "mdi:calendar-week-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的周报",
|
||||
"i18nKey": "route.personal-center_my-weekly",
|
||||
"icon": "mdi:calendar-week-outline",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-monthly",
|
||||
"path": "/personal-center/my-monthly",
|
||||
"component": "view.personal-center_my-monthly",
|
||||
"title": "我的月报",
|
||||
"routeTitle": "personal-center_my-monthly",
|
||||
"i18nKey": "route.personal-center_my-monthly",
|
||||
"icon": "mdi:calendar-month-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的月报",
|
||||
"i18nKey": "route.personal-center_my-monthly",
|
||||
"icon": "mdi:calendar-month-outline",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-performance",
|
||||
"path": "/personal-center/my-performance",
|
||||
"component": "view.personal-center_my-performance",
|
||||
"title": "我的绩效",
|
||||
"routeTitle": "personal-center_my-performance",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的绩效",
|
||||
"i18nKey": "route.personal-center_my-performance",
|
||||
"icon": "mdi:trophy-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_my-application",
|
||||
"path": "/personal-center/my-application",
|
||||
"component": "view.personal-center_my-application",
|
||||
"title": "我的申请",
|
||||
"routeTitle": "personal-center_my-application",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "我的申请",
|
||||
"i18nKey": "route.personal-center_my-application",
|
||||
"icon": "mdi:file-document-outline",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_pending-approval",
|
||||
"path": "/personal-center/pending-approval",
|
||||
"component": "view.personal-center_pending-approval",
|
||||
"title": "待我审批",
|
||||
"routeTitle": "personal-center_pending-approval",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "待我审批",
|
||||
"i18nKey": "route.personal-center_pending-approval",
|
||||
"icon": "mdi:check-decagram-outline",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "system_user",
|
||||
"path": "/system/user",
|
||||
@@ -172,6 +634,105 @@
|
||||
"parentName": "system",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "system_user-management-relation",
|
||||
"path": "/system/user-management-relation",
|
||||
"component": "view.system_user-management-relation",
|
||||
"title": "管理链路",
|
||||
"routeTitle": "system_user-management-relation",
|
||||
"i18nKey": "route.system_user-management-relation",
|
||||
"icon": null,
|
||||
"localIcon": null,
|
||||
"order": null,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "管理链路",
|
||||
"i18nKey": "route.system_user-management-relation",
|
||||
"icon": null,
|
||||
"localIcon": null,
|
||||
"order": null,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "system",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "infra_state-machine",
|
||||
"path": "/infra/state-machine",
|
||||
"component": "view.infra_state-machine",
|
||||
"title": "状态机管理",
|
||||
"routeTitle": "infra_state-machine",
|
||||
"i18nKey": "route.infra_state-machine",
|
||||
"icon": "mdi:state-machine",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "状态机管理",
|
||||
"i18nKey": "route.infra_state-machine",
|
||||
"icon": "mdi:state-machine",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "infra",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "infra_rd-code",
|
||||
"path": "/infra/rd-code",
|
||||
"component": "view.infra_rd-code",
|
||||
"title": "研发令号",
|
||||
"routeTitle": "infra_rd-code",
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "研发令号",
|
||||
"i18nKey": "route.infra_rd-code",
|
||||
"icon": "mdi:identifier",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "infra",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@antv/g2": "5.4.0",
|
||||
"@antv/g6": "5.0.49",
|
||||
"@better-scroll/core": "2.5.1",
|
||||
"@iconify-vue/mingcute": "^1.0.5",
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@sa/axios": "workspace:*",
|
||||
"@sa/color": "workspace:*",
|
||||
@@ -54,6 +55,8 @@
|
||||
"@visactor/vue-vtable": "1.19.8",
|
||||
"@vueuse/components": "13.9.0",
|
||||
"@vueuse/core": "13.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.18",
|
||||
"defu": "^6.1.4",
|
||||
@@ -77,7 +80,6 @@
|
||||
"vue-i18n": "11.1.11",
|
||||
"vue-pdf-embed": "2.1.3",
|
||||
"vue-router": "4.5.1",
|
||||
"wangeditor": "4.7.15",
|
||||
"xgplayer": "3.0.23",
|
||||
"xlsx": "0.18.5"
|
||||
},
|
||||
@@ -89,7 +91,6 @@
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/bmapgl": "0.0.7",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@types/node": "24.3.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.5.0",
|
||||
|
||||
466
pnpm-lock.yaml
generated
466
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
'@better-scroll/core':
|
||||
specifier: 2.5.1
|
||||
version: 2.5.1
|
||||
'@iconify-vue/mingcute':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(vue@3.5.20(typescript@5.8.3))
|
||||
'@iconify/vue':
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0(vue@3.5.20(typescript@5.8.3))
|
||||
@@ -59,6 +62,12 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: 13.9.0
|
||||
version: 13.9.0(vue@3.5.20(typescript@5.8.3))
|
||||
'@wangeditor/editor':
|
||||
specifier: ^5.1.23
|
||||
version: 5.1.23
|
||||
'@wangeditor/editor-for-vue':
|
||||
specifier: ^5.1.12
|
||||
version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.20(typescript@5.8.3))
|
||||
clipboard:
|
||||
specifier: 2.0.11
|
||||
version: 2.0.11
|
||||
@@ -128,9 +137,6 @@ importers:
|
||||
vue-router:
|
||||
specifier: 4.5.1
|
||||
version: 4.5.1(vue@3.5.20(typescript@5.8.3))
|
||||
wangeditor:
|
||||
specifier: 4.7.15
|
||||
version: 4.7.15
|
||||
xgplayer:
|
||||
specifier: 3.0.23
|
||||
version: 3.0.23(core-js@3.49.0)
|
||||
@@ -159,9 +165,6 @@ importers:
|
||||
'@types/bmapgl':
|
||||
specifier: 0.0.7
|
||||
version: 0.0.7
|
||||
'@types/dompurify':
|
||||
specifier: 3.2.0
|
||||
version: 3.2.0
|
||||
'@types/node':
|
||||
specifier: 24.3.0
|
||||
version: 24.3.0
|
||||
@@ -560,10 +563,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime-corejs3@7.29.2':
|
||||
resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.29.2':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -858,6 +857,14 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@iconify-vue/mingcute@1.0.5':
|
||||
resolution: {integrity: sha512-9g/iEU2XdobbfS6vKp01btfBlPiMqlqa+GujwYOc5WVJierhKt3dF0+tamomdk9vYcIsJiGcqOaKvrJF0g6prA==}
|
||||
|
||||
'@iconify/css-vue@1.0.2':
|
||||
resolution: {integrity: sha512-KXG9zXTMmJLi1AF2ket+YWUGdSqFvIMSnCO789uOVpba6SZhqeUttu0JIaEcq2dNlt4oonwdtMyerkpRkAFYhw==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.0'
|
||||
|
||||
'@iconify/json@2.2.380':
|
||||
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
|
||||
|
||||
@@ -1399,6 +1406,9 @@ packages:
|
||||
'@sxzz/popperjs-es@2.11.8':
|
||||
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
|
||||
|
||||
'@transloadit/prettier-bytes@0.0.7':
|
||||
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
|
||||
|
||||
'@turf/boolean-clockwise@6.5.0':
|
||||
resolution: {integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==}
|
||||
|
||||
@@ -1489,10 +1499,6 @@ packages:
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -1502,6 +1508,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/event-emitter@0.3.5':
|
||||
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
@@ -1791,6 +1800,23 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@uppy/companion-client@2.2.2':
|
||||
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
|
||||
|
||||
'@uppy/core@2.3.4':
|
||||
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
|
||||
|
||||
'@uppy/store-default@2.1.1':
|
||||
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
|
||||
|
||||
'@uppy/utils@4.1.3':
|
||||
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
|
||||
|
||||
'@uppy/xhr-upload@2.1.3':
|
||||
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
|
||||
peerDependencies:
|
||||
'@uppy/core': ^2.3.3
|
||||
|
||||
'@visactor/vchart-theme@1.12.2':
|
||||
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
|
||||
peerDependencies:
|
||||
@@ -2015,6 +2041,93 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@wangeditor/basic-modules@1.1.7':
|
||||
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
|
||||
peerDependencies:
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
lodash.throttle: ^4.1.1
|
||||
nanoid: ^3.2.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/code-highlight@1.0.3':
|
||||
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
|
||||
peerDependencies:
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/core@1.1.19':
|
||||
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
|
||||
peerDependencies:
|
||||
'@uppy/core': ^2.1.1
|
||||
'@uppy/xhr-upload': ^2.0.3
|
||||
dom7: ^3.0.0
|
||||
is-hotkey: ^0.2.0
|
||||
lodash.camelcase: ^4.3.0
|
||||
lodash.clonedeep: ^4.5.0
|
||||
lodash.debounce: ^4.0.8
|
||||
lodash.foreach: ^4.5.0
|
||||
lodash.isequal: ^4.5.0
|
||||
lodash.throttle: ^4.1.1
|
||||
lodash.toarray: ^4.4.0
|
||||
nanoid: ^3.2.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/editor-for-vue@5.1.12':
|
||||
resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==}
|
||||
peerDependencies:
|
||||
'@wangeditor/editor': '>=5.1.0'
|
||||
vue: ^3.0.5
|
||||
|
||||
'@wangeditor/editor@5.1.23':
|
||||
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
|
||||
|
||||
'@wangeditor/list-module@1.0.5':
|
||||
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
|
||||
peerDependencies:
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/table-module@1.1.4':
|
||||
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
|
||||
peerDependencies:
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
lodash.isequal: ^4.5.0
|
||||
lodash.throttle: ^4.1.1
|
||||
nanoid: ^3.2.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/upload-image-module@1.0.2':
|
||||
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
|
||||
peerDependencies:
|
||||
'@uppy/core': ^2.0.3
|
||||
'@uppy/xhr-upload': ^2.0.3
|
||||
'@wangeditor/basic-modules': 1.x
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
lodash.foreach: ^4.5.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@wangeditor/video-module@1.1.4':
|
||||
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
|
||||
peerDependencies:
|
||||
'@uppy/core': ^2.1.4
|
||||
'@uppy/xhr-upload': ^2.0.7
|
||||
'@wangeditor/core': 1.x
|
||||
dom7: ^3.0.0
|
||||
nanoid: ^3.2.0
|
||||
slate: ^0.72.0
|
||||
snabbdom: ^3.1.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
|
||||
@@ -2434,6 +2547,9 @@ packages:
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
compute-scroll-into-view@1.0.20:
|
||||
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -2476,9 +2592,6 @@ packages:
|
||||
core-js-compat@3.49.0:
|
||||
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
||||
|
||||
core-js-pure@3.49.0:
|
||||
resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
@@ -2800,6 +2913,9 @@ packages:
|
||||
dom-serializer@1.4.1:
|
||||
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
|
||||
|
||||
dom7@3.0.0:
|
||||
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
|
||||
|
||||
domelementtype@1.3.1:
|
||||
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
|
||||
|
||||
@@ -3439,6 +3555,9 @@ packages:
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
html-void-elements@2.0.1:
|
||||
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -3450,6 +3569,9 @@ packages:
|
||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
i18next@20.6.1:
|
||||
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3486,6 +3608,9 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@9.0.21:
|
||||
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||
|
||||
immutable@5.1.5:
|
||||
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
|
||||
|
||||
@@ -3606,6 +3731,9 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-hotkey@0.2.0:
|
||||
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -3643,6 +3771,10 @@ packages:
|
||||
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-plain-object@5.0.0:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-regex@1.2.1:
|
||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3682,6 +3814,9 @@ packages:
|
||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-url@1.2.4:
|
||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3874,9 +4009,31 @@ packages:
|
||||
lodash: '*'
|
||||
lodash-es: '*'
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.clonedeep@4.5.0:
|
||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.foreach@4.5.0:
|
||||
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
lodash.toarray@4.4.0:
|
||||
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -3953,6 +4110,9 @@ packages:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-match@1.0.2:
|
||||
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4021,6 +4181,9 @@ packages:
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
namespace-emitter@2.0.1:
|
||||
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -4336,6 +4499,9 @@ packages:
|
||||
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
preact@10.29.1:
|
||||
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4366,6 +4532,10 @@ packages:
|
||||
print-js@1.6.0:
|
||||
resolution: {integrity: sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==}
|
||||
|
||||
prismjs@1.30.0:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4547,6 +4717,9 @@ packages:
|
||||
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
scroll-into-view-if-needed@2.2.31:
|
||||
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
|
||||
|
||||
select@1.1.2:
|
||||
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
|
||||
|
||||
@@ -4647,9 +4820,21 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
slate-history@0.66.0:
|
||||
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
|
||||
peerDependencies:
|
||||
slate: '>=0.65.3'
|
||||
|
||||
slate@0.72.8:
|
||||
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
|
||||
|
||||
slice-source@0.4.1:
|
||||
resolution: {integrity: sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==}
|
||||
|
||||
snabbdom@3.6.3:
|
||||
resolution: {integrity: sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4697,6 +4882,9 @@ packages:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
ssr-window@3.0.0:
|
||||
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
|
||||
|
||||
stable-hash-x@0.2.0:
|
||||
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -4859,6 +5047,9 @@ packages:
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tiny-warning@1.0.3:
|
||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||
|
||||
tinyexec@1.0.4:
|
||||
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5231,9 +5422,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
wangeditor@4.7.15:
|
||||
resolution: {integrity: sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==}
|
||||
|
||||
watchpack@2.5.1:
|
||||
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -5276,6 +5464,9 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wildcard@1.1.2:
|
||||
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -5742,10 +5933,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/runtime-corejs3@7.29.2':
|
||||
dependencies:
|
||||
core-js-pure: 3.49.0
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
@@ -5997,6 +6184,17 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
|
||||
'@iconify-vue/mingcute@1.0.5(vue@3.5.20(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@iconify/css-vue': 1.0.2(vue@3.5.20(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@iconify/css-vue@1.0.2(vue@3.5.20(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
'@iconify/json@2.2.380':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -6419,6 +6617,8 @@ snapshots:
|
||||
|
||||
'@sxzz/popperjs-es@2.11.8': {}
|
||||
|
||||
'@transloadit/prettier-bytes@0.0.7': {}
|
||||
|
||||
'@turf/boolean-clockwise@6.5.0':
|
||||
dependencies:
|
||||
'@turf/helpers': 6.5.0
|
||||
@@ -6510,10 +6710,6 @@ snapshots:
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.2.6
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -6526,6 +6722,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/event-emitter@0.3.5': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -6855,6 +7053,35 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@uppy/companion-client@2.2.2':
|
||||
dependencies:
|
||||
'@uppy/utils': 4.1.3
|
||||
namespace-emitter: 2.0.1
|
||||
|
||||
'@uppy/core@2.3.4':
|
||||
dependencies:
|
||||
'@transloadit/prettier-bytes': 0.0.7
|
||||
'@uppy/store-default': 2.1.1
|
||||
'@uppy/utils': 4.1.3
|
||||
lodash.throttle: 4.1.1
|
||||
mime-match: 1.0.2
|
||||
namespace-emitter: 2.0.1
|
||||
nanoid: 3.3.11
|
||||
preact: 10.29.1
|
||||
|
||||
'@uppy/store-default@2.1.1': {}
|
||||
|
||||
'@uppy/utils@4.1.3':
|
||||
dependencies:
|
||||
lodash.throttle: 4.1.1
|
||||
|
||||
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
|
||||
dependencies:
|
||||
'@uppy/companion-client': 2.2.2
|
||||
'@uppy/core': 2.3.4
|
||||
'@uppy/utils': 4.1.3
|
||||
nanoid: 3.3.11
|
||||
|
||||
'@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
|
||||
dependencies:
|
||||
'@visactor/vchart': 2.0.4
|
||||
@@ -7284,6 +7511,114 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
is-url: 1.2.4
|
||||
lodash.throttle: 4.1.1
|
||||
nanoid: 3.3.11
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
prismjs: 1.30.0
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@types/event-emitter': 0.3.5
|
||||
'@uppy/core': 2.3.4
|
||||
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||
dom7: 3.0.0
|
||||
event-emitter: 0.3.5
|
||||
html-void-elements: 2.0.1
|
||||
i18next: 20.6.1
|
||||
is-hotkey: 0.2.0
|
||||
lodash.camelcase: 4.3.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.foreach: 4.5.0
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.throttle: 4.1.1
|
||||
lodash.toarray: 4.4.0
|
||||
nanoid: 3.3.11
|
||||
scroll-into-view-if-needed: 2.2.31
|
||||
slate: 0.72.8
|
||||
slate-history: 0.66.0(slate@0.72.8)
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.20(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@wangeditor/editor': 5.1.23
|
||||
vue: 3.5.20(typescript@5.8.3)
|
||||
|
||||
'@wangeditor/editor@5.1.23':
|
||||
dependencies:
|
||||
'@uppy/core': 2.3.4
|
||||
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
is-hotkey: 0.2.0
|
||||
lodash.camelcase: 4.3.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.foreach: 4.5.0
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.throttle: 4.1.1
|
||||
lodash.toarray: 4.4.0
|
||||
nanoid: 3.3.11
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.throttle: 4.1.1
|
||||
nanoid: 3.3.11
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@uppy/core': 2.3.4
|
||||
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
lodash.foreach: 4.5.0
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||
dependencies:
|
||||
'@uppy/core': 2.3.4
|
||||
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||
dom7: 3.0.0
|
||||
nanoid: 3.3.11
|
||||
slate: 0.72.8
|
||||
snabbdom: 3.6.3
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
'@webassemblyjs/helper-numbers': 1.13.2
|
||||
@@ -7741,6 +8076,8 @@ snapshots:
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
compute-scroll-into-view@1.0.20: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@1.4.11:
|
||||
@@ -7778,8 +8115,6 @@ snapshots:
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
|
||||
core-js-pure@3.49.0: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
@@ -8093,6 +8428,10 @@ snapshots:
|
||||
domhandler: 4.3.1
|
||||
entities: 2.2.0
|
||||
|
||||
dom7@3.0.0:
|
||||
dependencies:
|
||||
ssr-window: 3.0.0
|
||||
|
||||
domelementtype@1.3.1: {}
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
@@ -8895,6 +9234,8 @@ snapshots:
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
html-void-elements@2.0.1: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
@@ -8911,6 +9252,10 @@ snapshots:
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
i18next@20.6.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -8935,6 +9280,8 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@9.0.21: {}
|
||||
|
||||
immutable@5.1.5: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -9052,6 +9399,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-hotkey@0.2.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
@@ -9079,6 +9428,8 @@ snapshots:
|
||||
dependencies:
|
||||
isobject: 3.0.1
|
||||
|
||||
is-plain-object@5.0.0: {}
|
||||
|
||||
is-regex@1.2.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -9115,6 +9466,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -9281,8 +9634,22 @@ snapshots:
|
||||
lodash: 4.17.23
|
||||
lodash-es: 4.17.23
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.clonedeep@4.5.0: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.foreach@4.5.0: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
lodash.toarray@4.4.0: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
@@ -9363,6 +9730,10 @@ snapshots:
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-match@1.0.2:
|
||||
dependencies:
|
||||
wildcard: 1.1.2
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
@@ -9430,6 +9801,8 @@ snapshots:
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
namespace-emitter@2.0.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
@@ -9738,6 +10111,8 @@ snapshots:
|
||||
posthtml-parser: 0.2.1
|
||||
posthtml-render: 1.4.0
|
||||
|
||||
preact@10.29.1: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-linter-helpers@1.0.1:
|
||||
@@ -9758,6 +10133,8 @@ snapshots:
|
||||
|
||||
print-js@1.6.0: {}
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
@@ -9973,6 +10350,10 @@ snapshots:
|
||||
ajv-formats: 2.1.1(ajv@8.18.0)
|
||||
ajv-keywords: 5.1.0(ajv@8.18.0)
|
||||
|
||||
scroll-into-view-if-needed@2.2.31:
|
||||
dependencies:
|
||||
compute-scroll-into-view: 1.0.20
|
||||
|
||||
select@1.1.2: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
@@ -10094,8 +10475,21 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slate-history@0.66.0(slate@0.72.8):
|
||||
dependencies:
|
||||
is-plain-object: 5.0.0
|
||||
slate: 0.72.8
|
||||
|
||||
slate@0.72.8:
|
||||
dependencies:
|
||||
immer: 9.0.21
|
||||
is-plain-object: 5.0.0
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
slice-source@0.4.1: {}
|
||||
|
||||
snabbdom@3.6.3: {}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
dependencies:
|
||||
define-property: 1.0.0
|
||||
@@ -10150,6 +10544,8 @@ snapshots:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
ssr-window@3.0.0: {}
|
||||
|
||||
stable-hash-x@0.2.0: {}
|
||||
|
||||
stable@0.1.8: {}
|
||||
@@ -10320,6 +10716,8 @@ snapshots:
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tiny-warning@1.0.3: {}
|
||||
|
||||
tinyexec@1.0.4: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
@@ -10753,12 +11151,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
wangeditor@4.7.15:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/runtime-corejs3': 7.29.2
|
||||
tslib: 2.8.1
|
||||
|
||||
watchpack@2.5.1:
|
||||
dependencies:
|
||||
glob-to-regexp: 0.4.1
|
||||
@@ -10845,6 +11237,8 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wildcard@1.1.2: {}
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
wolfy87-eventemitter@5.2.9: {}
|
||||
|
||||
1018
src/components/custom/attendee-user-picker.vue
Normal file
1018
src/components/custom/attendee-user-picker.vue
Normal file
File diff suppressed because it is too large
Load Diff
718
src/components/custom/business-attachment-uploader.vue
Normal file
718
src/components/custom/business-attachment-uploader.vue
Normal file
@@ -0,0 +1,718 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
|
||||
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
|
||||
|
||||
defineOptions({ name: 'BusinessAttachmentUploader' });
|
||||
|
||||
interface Props {
|
||||
/** 上传目录,传给后端 directory 字段 */
|
||||
directory?: string;
|
||||
/** 数量上限,默认 20(与后端 AttachmentValidator 一致) */
|
||||
max?: number;
|
||||
/** 单文件大小上限 MB(前端兜底;最终由 /system/file/upload 拦截) */
|
||||
maxFileSizeMB?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
|
||||
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
|
||||
*/
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
directory: undefined,
|
||||
max: 20,
|
||||
maxFileSizeMB: 50,
|
||||
disabled: false,
|
||||
flat: false
|
||||
});
|
||||
|
||||
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
|
||||
|
||||
/** 给用户看的简短分类(hint 行展示) */
|
||||
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
|
||||
|
||||
// 与后端 AttachmentValidator 白/黑名单保持一致(5.16)
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'txt',
|
||||
'md',
|
||||
'csv',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'mp4',
|
||||
'mp3'
|
||||
]);
|
||||
|
||||
const FORBIDDEN_EXTENSIONS = new Set([
|
||||
'exe',
|
||||
'bat',
|
||||
'cmd',
|
||||
'sh',
|
||||
'ps1',
|
||||
'msi',
|
||||
'dll',
|
||||
'jar',
|
||||
'war',
|
||||
'php',
|
||||
'jsp',
|
||||
'asp',
|
||||
'aspx',
|
||||
'py',
|
||||
'rb',
|
||||
'pl',
|
||||
'com',
|
||||
'scr',
|
||||
'vbs',
|
||||
'js'
|
||||
]);
|
||||
|
||||
interface PendingItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const pending = ref<PendingItem[]>([]);
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const isUnmounting = ref(false);
|
||||
|
||||
/**
|
||||
* 会话级清理账本:
|
||||
* - originalIds: 弹层打开时已存在的 fileId(编辑模式下来自 rowData.attachments)。
|
||||
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
|
||||
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
|
||||
* - addedIds: 本次会话内上传成功的 fileId
|
||||
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId(含 original 和 added 两类)
|
||||
* - committed: commit() 调用后置 true,阻止后续 rollback 误删
|
||||
*
|
||||
* UI 显示 = model(已减去 pendingDelete 项)
|
||||
* 真删时机:commit() 删 pendingDelete;rollback() 删 addedIds(除非 committed)
|
||||
*/
|
||||
interface UploadSession {
|
||||
originalIds: Set<string>;
|
||||
addedIds: Set<string>;
|
||||
pendingDeleteIds: Set<string>;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
const session = reactive<UploadSession>({
|
||||
originalIds: new Set<string>(),
|
||||
addedIds: new Set<string>(),
|
||||
pendingDeleteIds: new Set<string>(),
|
||||
committed: false
|
||||
});
|
||||
|
||||
const totalCount = computed(() => model.value.length + pending.value.length);
|
||||
const isFull = computed(() => totalCount.value >= props.max);
|
||||
const hasUploading = computed(() => pending.value.length > 0);
|
||||
|
||||
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
|
||||
|
||||
/**
|
||||
* 列表区拆成"直接展示"和"折叠浮层"两组:
|
||||
* - flat:全部直接展示(适合本身已在 popover 里)
|
||||
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
|
||||
*/
|
||||
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
|
||||
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
|
||||
|
||||
function isImage(item: Api.Project.AttachmentItem) {
|
||||
if (item.contentType?.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
return IMAGE_EXTENSIONS.has(getExtension(item.name));
|
||||
}
|
||||
|
||||
interface ImagePreviewState {
|
||||
visible: boolean;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
const imagePreview = reactive<ImagePreviewState>({
|
||||
visible: false,
|
||||
urls: []
|
||||
});
|
||||
|
||||
function getExtension(name: string) {
|
||||
const idx = name.lastIndexOf('.');
|
||||
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
if (!file.name) {
|
||||
return '文件名为空';
|
||||
}
|
||||
if (file.name.length > 255) {
|
||||
return '文件名超过 255 字符';
|
||||
}
|
||||
|
||||
const ext = getExtension(file.name);
|
||||
if (!ext) {
|
||||
return '文件缺少扩展名';
|
||||
}
|
||||
if (FORBIDDEN_EXTENSIONS.has(ext)) {
|
||||
return `不允许上传 .${ext} 文件`;
|
||||
}
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||
return `暂不支持 .${ext} 文件`;
|
||||
}
|
||||
|
||||
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
|
||||
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function triggerSelect() {
|
||||
if (props.disabled || isFull.value) {
|
||||
return;
|
||||
}
|
||||
inputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
input.value = '';
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = props.max - totalCount.value;
|
||||
if (files.length > remaining) {
|
||||
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles: File[] = [];
|
||||
files.forEach(file => {
|
||||
const err = validateFile(file);
|
||||
if (err) {
|
||||
window.$message?.error(`${file.name}:${err}`);
|
||||
return;
|
||||
}
|
||||
validFiles.push(file);
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(validFiles.map(uploadOne));
|
||||
}
|
||||
|
||||
async function uploadOne(file: File) {
|
||||
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
pending.value = [...pending.value, { id: tempId, name: file.name }];
|
||||
|
||||
try {
|
||||
const result = await uploadFile(file, props.directory);
|
||||
if (result.error || !result.data) {
|
||||
window.$message?.error(`${file.name}:上传失败`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, url } = result.data;
|
||||
|
||||
// 组件已卸载(用户上传过程中关弹层):onBeforeUnmount 已跑过且看不到这个 id,
|
||||
// 这里立刻调删除,避免孤儿文件
|
||||
if (isUnmounting.value) {
|
||||
deleteFile(id).catch(() => {
|
||||
// 已卸载场景下 console.warn 也访问不到 component scope,这里静默吞掉
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = [
|
||||
...model.value,
|
||||
{
|
||||
fileId: id,
|
||||
url,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
contentType: file.type || undefined
|
||||
}
|
||||
];
|
||||
session.addedIds.add(id);
|
||||
} finally {
|
||||
pending.value = pending.value.filter(item => item.id !== tempId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(item: Api.Project.AttachmentItem) {
|
||||
removeAttachmentByFileId(item.fileId);
|
||||
}
|
||||
|
||||
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
|
||||
const { data, error } = await downloadFile(item.fileId);
|
||||
if (error || !data) {
|
||||
window.$message?.error(`${item.name}:加载失败`);
|
||||
return null;
|
||||
}
|
||||
return URL.createObjectURL(data);
|
||||
}
|
||||
|
||||
async function handleDownload(item: Api.Project.AttachmentItem) {
|
||||
const blobUrl = await fetchAsBlobUrl(item);
|
||||
if (!blobUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = item.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
|
||||
const blobUrl = await fetchAsBlobUrl(item);
|
||||
if (!blobUrl) {
|
||||
return;
|
||||
}
|
||||
imagePreview.urls = [blobUrl];
|
||||
imagePreview.visible = true;
|
||||
}
|
||||
|
||||
function handleClosePreview() {
|
||||
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
|
||||
imagePreview.urls = [];
|
||||
imagePreview.visible = false;
|
||||
}
|
||||
|
||||
/** 文件名点击的统一入口:图片走预览,其余走下载 */
|
||||
function handleOpen(item: Api.Project.AttachmentItem) {
|
||||
if (isImage(item)) {
|
||||
handlePreviewImage(item);
|
||||
} else {
|
||||
handleDownload(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
|
||||
function removeAttachmentByFileId(fileId: string) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
const idx = model.value.findIndex(item => item.fileId === fileId);
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
session.pendingDeleteIds.add(fileId);
|
||||
model.value = model.value.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
if (!size && size !== 0) {
|
||||
return '';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size}B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)}KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一批 fileId。fire-and-forget:
|
||||
* - 不阻塞 UI;任何失败仅 console.warn
|
||||
* - 后端返回 1001003001(文件不存在)视为成功
|
||||
*/
|
||||
async function deleteMany(ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled(
|
||||
ids.map(async id => {
|
||||
const { error } = await deleteFile(id);
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 等关闭弹层时先等再清理。设上限 5s,避免极端网络下 commit/rollback 永久挂起。 */
|
||||
async function waitForPending(maxWaitMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (pending.value.length > 0) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
|
||||
return;
|
||||
}
|
||||
// polling: 需要在循环里 await,suppress 即可
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* 父组件在【打开弹层并填充 model 之后】调用。
|
||||
* 把当前 model 视为 original,清空 added / pendingDelete,重置 committed。
|
||||
*/
|
||||
initSession() {
|
||||
session.originalIds = new Set(model.value.map(item => item.fileId));
|
||||
session.addedIds.clear();
|
||||
session.pendingDeleteIds.clear();
|
||||
session.committed = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件在【业务保存成功后】调用。
|
||||
* 真删 pendingDelete(含 original 和 added 两类);置 committed 阻止后续 rollback。
|
||||
*/
|
||||
async commit() {
|
||||
await waitForPending();
|
||||
const ids = Array.from(session.pendingDeleteIds);
|
||||
session.pendingDeleteIds.clear();
|
||||
session.addedIds.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(ids);
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||||
* 真删 addedIds(保留 original);committed=true 时跳过。
|
||||
*/
|
||||
async rollback() {
|
||||
if (session.committed) {
|
||||
return;
|
||||
}
|
||||
await waitForPending();
|
||||
const ids = Array.from(session.addedIds);
|
||||
session.addedIds.clear();
|
||||
session.pendingDeleteIds.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(ids);
|
||||
},
|
||||
|
||||
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
|
||||
get hasUploading() {
|
||||
return hasUploading.value;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
|
||||
isUnmounting.value = true;
|
||||
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||||
// deleteMany 内部已 swallow 单项失败,这里不再 await,fire-and-forget
|
||||
if (!session.committed) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
deleteMany(Array.from(session.addedIds));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-attachment-uploader">
|
||||
<div v-if="!disabled" class="business-attachment-uploader__trigger">
|
||||
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
|
||||
<span class="business-attachment-uploader__hint">
|
||||
最多 {{ max }} 个,已选 {{ totalCount }} 个;单文件 ≤ {{ maxFileSizeMB }}MB
|
||||
<ElTooltip placement="top">
|
||||
<template #content>
|
||||
<div class="business-attachment-uploader__hint-tooltip">
|
||||
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
|
||||
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名:{{ acceptExtensionsList }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="business-attachment-uploader__input"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
|
||||
|
||||
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
|
||||
<!-- 直接展示:默认仅首项;flat 模式全部 -->
|
||||
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
|
||||
<ElIcon class="business-attachment-uploader__icon">
|
||||
<Picture v-if="isImage(item)" />
|
||||
<Document v-else />
|
||||
</ElIcon>
|
||||
<ElLink
|
||||
type="primary"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</ElLink>
|
||||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||
</li>
|
||||
|
||||
<!-- 折叠提示:>1 个时显示,hover 弹完整列表(flat 模式下永不出现) -->
|
||||
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
|
||||
<ElPopover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
:width="380"
|
||||
:show-after="200"
|
||||
popper-class="business-attachment-uploader__popover"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="business-attachment-uploader__more">
|
||||
还有 {{ popoverAttachments.length }} 个附件
|
||||
<ElIcon><ArrowDown /></ElIcon>
|
||||
</span>
|
||||
</template>
|
||||
<ul class="business-attachment-uploader__popover-list">
|
||||
<li
|
||||
v-for="item in popoverAttachments"
|
||||
:key="`popover-${item.fileId}`"
|
||||
class="business-attachment-uploader__item"
|
||||
>
|
||||
<ElIcon class="business-attachment-uploader__icon">
|
||||
<Picture v-if="isImage(item)" />
|
||||
<Document v-else />
|
||||
</ElIcon>
|
||||
<ElLink
|
||||
type="primary"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</ElLink>
|
||||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||
</li>
|
||||
</ul>
|
||||
</ElPopover>
|
||||
</li>
|
||||
|
||||
<!-- pending 项不折叠:让用户能持续看到上传进度 -->
|
||||
<li
|
||||
v-for="item in pending"
|
||||
:key="`pending-${item.id}`"
|
||||
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
|
||||
>
|
||||
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
|
||||
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
|
||||
<span class="business-attachment-uploader__status">上传中…</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ElImageViewer
|
||||
v-if="imagePreview.visible"
|
||||
:url-list="imagePreview.urls"
|
||||
hide-on-click-modal
|
||||
@close="handleClosePreview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.business-attachment-uploader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__hint-icon {
|
||||
color: rgb(100 116 139 / 88%);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__hint-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__hint-tooltip-ext {
|
||||
word-break: break-all;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__empty {
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-blank);
|
||||
font-size: 13px;
|
||||
|
||||
&--pending {
|
||||
background: var(--el-fill-color-light);
|
||||
color: rgb(100 116 139 / 88%);
|
||||
}
|
||||
}
|
||||
|
||||
.business-attachment-uploader__icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-attachment-uploader__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__size {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__status {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__more-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// 浮层非 scoped:popper 渲染到 body
|
||||
.business-attachment-uploader__popover {
|
||||
padding: 8px 4px !important;
|
||||
|
||||
.business-attachment-uploader__popover-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 280px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.business-attachment-uploader__icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-attachment-uploader__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.business-attachment-uploader__size {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
569
src/components/custom/business-date-range-picker.vue
Normal file
569
src/components/custom/business-date-range-picker.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'BusinessDateRangePicker' });
|
||||
|
||||
type DateRangeValue = [string, string];
|
||||
|
||||
interface DateRangeShortcut {
|
||||
label: string;
|
||||
value: DateRangeValue | (() => DateRangeValue);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
shortcuts?: DateRangeShortcut[];
|
||||
popoverWidth?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择日期范围',
|
||||
disabled: false,
|
||||
shortcuts: () => [],
|
||||
popoverWidth: 458
|
||||
});
|
||||
|
||||
const model = defineModel<DateRangeValue>({
|
||||
default: () => ['', '']
|
||||
});
|
||||
|
||||
const popoverVisible = ref(false);
|
||||
const activeTab = ref<'advanced' | 'custom'>('custom');
|
||||
const draftRange = ref<DateRangeValue>(normalizeDateRange(model.value));
|
||||
const panelMonth = ref(dayjs().startOf('month'));
|
||||
|
||||
const displayText = computed(() => {
|
||||
const normalizedRange = normalizeDateRange(model.value);
|
||||
|
||||
return normalizedRange.every(Boolean) ? normalizedRange.join(' ~ ') : '';
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !isCompleteDateRange(draftRange.value);
|
||||
});
|
||||
|
||||
const defaultShortcuts = computed<DateRangeShortcut[]>(() => [
|
||||
{
|
||||
label: '最近 7 天',
|
||||
value: () => buildRecentDateRange(7)
|
||||
},
|
||||
{
|
||||
label: '最近 30 天',
|
||||
value: () => buildRecentDateRange(30)
|
||||
},
|
||||
{
|
||||
label: '本周',
|
||||
value: () => [dayjs().startOf('week').format('YYYY-MM-DD'), dayjs().endOf('week').format('YYYY-MM-DD')]
|
||||
},
|
||||
{
|
||||
label: '本月',
|
||||
value: () => [dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD')]
|
||||
}
|
||||
]);
|
||||
|
||||
const resolvedShortcuts = computed(() => (props.shortcuts?.length ? props.shortcuts : defaultShortcuts.value));
|
||||
|
||||
const panelTitle = computed(() => panelMonth.value.format('YYYY 年 M 月'));
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const startDate = panelMonth.value.startOf('month').startOf('week');
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = startDate.add(index, 'day');
|
||||
const dateText = date.format('YYYY-MM-DD');
|
||||
|
||||
return {
|
||||
date,
|
||||
dateText,
|
||||
dayText: date.format('D'),
|
||||
isCurrentMonth: date.month() === panelMonth.value.month(),
|
||||
isSelected: isSelectedDate(dateText),
|
||||
isInRange: isInSelectedRange(dateText),
|
||||
isStart: draftRange.value[0] === dateText,
|
||||
isEnd: draftRange.value[1] === dateText
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const activeShortcutLabel = computed(() => {
|
||||
const matchedShortcut = resolvedShortcuts.value.find(shortcut => {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
return shortcutRange[0] === draftRange.value[0] && shortcutRange[1] === draftRange.value[1];
|
||||
});
|
||||
|
||||
return matchedShortcut?.label || '';
|
||||
});
|
||||
|
||||
function buildRecentDateRange(days: number): DateRangeValue {
|
||||
const end = dayjs();
|
||||
const start = dayjs().subtract(Math.max(days - 1, 0), 'day');
|
||||
|
||||
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
}
|
||||
|
||||
function normalizeDateRange(value: readonly string[] | null | undefined): DateRangeValue {
|
||||
const [startDate = '', endDate = ''] = value || [];
|
||||
|
||||
return [formatDate(startDate), formatDate(endDate)];
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '';
|
||||
}
|
||||
|
||||
function isCompleteDateRange(value: readonly string[]) {
|
||||
return value.length === 2 && value.every(item => dayjs(item).isValid());
|
||||
}
|
||||
|
||||
function syncPanelMonth(value: readonly string[]) {
|
||||
const [startDate, endDate] = value;
|
||||
const candidateDate = startDate || endDate;
|
||||
const parsed = dayjs(candidateDate);
|
||||
|
||||
panelMonth.value = parsed.isValid() ? parsed.startOf('month') : dayjs().startOf('month');
|
||||
}
|
||||
|
||||
function isSelectedDate(dateText: string) {
|
||||
return draftRange.value.includes(dateText);
|
||||
}
|
||||
|
||||
function isInSelectedRange(dateText: string) {
|
||||
if (!isCompleteDateRange(draftRange.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = dayjs(dateText);
|
||||
const startDate = dayjs(draftRange.value[0]);
|
||||
const endDate = dayjs(draftRange.value[1]);
|
||||
|
||||
return current.isAfter(startDate, 'day') && current.isBefore(endDate, 'day');
|
||||
}
|
||||
|
||||
function resolveShortcutValue(shortcut: DateRangeShortcut) {
|
||||
return normalizeDateRange(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value);
|
||||
}
|
||||
|
||||
function updateModel(value: DateRangeValue) {
|
||||
const normalizedRange = normalizeDateRange(value);
|
||||
|
||||
if (!isCompleteDateRange(normalizedRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = normalizedRange;
|
||||
}
|
||||
|
||||
function handleVisibleChange(currentVisible: boolean) {
|
||||
popoverVisible.value = currentVisible;
|
||||
|
||||
if (currentVisible) {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
syncPanelMonth(draftRange.value);
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
}
|
||||
|
||||
function handleShortcutClick(shortcut: DateRangeShortcut) {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
draftRange.value = shortcutRange;
|
||||
syncPanelMonth(shortcutRange);
|
||||
}
|
||||
|
||||
function handleDateClick(dateText: string) {
|
||||
const [startDate, endDate] = draftRange.value;
|
||||
|
||||
if (!startDate || (startDate && endDate)) {
|
||||
draftRange.value = [dateText, ''];
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayjs(dateText).isBefore(dayjs(startDate), 'day')) {
|
||||
draftRange.value = [dateText, startDate];
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = [startDate, dateText];
|
||||
}
|
||||
|
||||
function switchPanelMonth(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'month');
|
||||
}
|
||||
|
||||
function switchPanelYear(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'year');
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateModel(draftRange.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
value => {
|
||||
if (!popoverVisible.value) {
|
||||
draftRange.value = normalizeDateRange(value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="popoverVisible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="popoverWidth"
|
||||
popper-class="business-date-range-picker__popper"
|
||||
:disabled="disabled"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<ElInput
|
||||
:model-value="displayText"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
class="business-date-range-picker__input"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</template>
|
||||
|
||||
<div class="business-date-range-picker__panel">
|
||||
<aside class="business-date-range-picker__shortcuts">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__shortcut"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</aside>
|
||||
|
||||
<section class="business-date-range-picker__main">
|
||||
<div class="business-date-range-picker__tabs">
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'advanced' }"
|
||||
type="button"
|
||||
@click="activeTab = 'advanced'"
|
||||
>
|
||||
高级选项
|
||||
</button>
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'custom' }"
|
||||
type="button"
|
||||
@click="activeTab = 'custom'"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'advanced'" class="business-date-range-picker__advanced">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
plain
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__advanced-button"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="business-date-range-picker__custom">
|
||||
<div class="business-date-range-picker__fields">
|
||||
<ElInput v-model="draftRange[0]" class="business-date-range-picker__field" />
|
||||
<span class="business-date-range-picker__separator">—</span>
|
||||
<ElInput v-model="draftRange[1]" class="business-date-range-picker__field" />
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__calendar">
|
||||
<div class="business-date-range-picker__calendar-header">
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(-1)">
|
||||
«
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(-1)">
|
||||
‹
|
||||
</button>
|
||||
<span class="business-date-range-picker__calendar-title">{{ panelTitle }}</span>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(1)">
|
||||
›
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(1)">
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__weekdays">
|
||||
<span>日</span>
|
||||
<span>一</span>
|
||||
<span>二</span>
|
||||
<span>三</span>
|
||||
<span>四</span>
|
||||
<span>五</span>
|
||||
<span>六</span>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__days">
|
||||
<button
|
||||
v-for="cell in calendarCells"
|
||||
:key="cell.dateText"
|
||||
type="button"
|
||||
class="business-date-range-picker__day"
|
||||
:class="{
|
||||
'business-date-range-picker__day--muted': !cell.isCurrentMonth,
|
||||
'business-date-range-picker__day--selected': cell.isSelected,
|
||||
'business-date-range-picker__day--in-range': cell.isInRange,
|
||||
'business-date-range-picker__day--start': cell.isStart,
|
||||
'business-date-range-picker__day--end': cell.isEnd
|
||||
}"
|
||||
@click="handleDateClick(cell.dateText)"
|
||||
>
|
||||
<span>{{ cell.dayText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__footer">
|
||||
<ElButton @click="handleCancel">取消</ElButton>
|
||||
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确定</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-date-range-picker__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.business-date-range-picker__panel {
|
||||
display: grid;
|
||||
grid-template-columns: 102px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 10px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcut {
|
||||
width: 78px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active::after {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
left: 12px;
|
||||
height: 2px;
|
||||
background-color: var(--el-color-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 230px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__custom {
|
||||
padding: 10px 8px 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__separator {
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
:global(.business-date-range-picker__popper.el-popover.el-popper) {
|
||||
padding: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 28px minmax(0, 1fr) 28px 28px;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays,
|
||||
.business-date-range-picker__days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__days {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day:hover span {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--in-range {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--selected span {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--start {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--end {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -49,8 +49,8 @@ const visible = defineModel<boolean>({
|
||||
|
||||
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
|
||||
sm: '520px',
|
||||
md: '640px',
|
||||
lg: '720px'
|
||||
md: '720px',
|
||||
lg: '960px'
|
||||
};
|
||||
|
||||
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
|
||||
|
||||
461
src/components/custom/business-rich-text-editor.vue
Normal file
461
src/components/custom/business-rich-text-editor.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import { ElImageViewer } from 'element-plus';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';
|
||||
|
||||
defineOptions({ name: 'BusinessRichTextEditor' });
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
height?: number | string;
|
||||
/** 上传目录,传给后端 directory 字段 */
|
||||
uploadDirectory?: string;
|
||||
/** 单张图片大小上限(MB),默认 5 */
|
||||
maxImageSizeMB?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请输入内容',
|
||||
disabled: false,
|
||||
height: 320,
|
||||
uploadDirectory: undefined,
|
||||
maxImageSizeMB: 5
|
||||
});
|
||||
|
||||
const model = defineModel<string | null | undefined>({ default: '' });
|
||||
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const containerRef = ref<HTMLElement>();
|
||||
|
||||
/**
|
||||
* 图片预览:
|
||||
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
|
||||
* - 点按钮 → ElImageViewer 多图模式,url-list = 当前 HTML 里所有 img src(按出现顺序去重)
|
||||
* - 编辑态与 disabled 只读态共用
|
||||
*/
|
||||
const zoomBtnVisible = ref(false);
|
||||
const zoomBtnStyle = ref<Record<string, string>>({});
|
||||
const hoveredImageSrc = ref('');
|
||||
|
||||
const viewerVisible = ref(false);
|
||||
const viewerUrlList = ref<string[]>([]);
|
||||
const viewerIndex = ref(0);
|
||||
|
||||
let hideZoomBtnTimer: number | undefined;
|
||||
|
||||
function cancelHideZoomBtn() {
|
||||
if (hideZoomBtnTimer !== undefined) {
|
||||
window.clearTimeout(hideZoomBtnTimer);
|
||||
hideZoomBtnTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHideZoomBtn() {
|
||||
cancelHideZoomBtn();
|
||||
hideZoomBtnTimer = window.setTimeout(() => {
|
||||
zoomBtnVisible.value = false;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function positionZoomBtn(img: HTMLImageElement) {
|
||||
const container = containerRef.value;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const btnSize = 28;
|
||||
const gap = 8;
|
||||
zoomBtnStyle.value = {
|
||||
top: `${imgRect.top - containerRect.top + gap}px`,
|
||||
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
|
||||
};
|
||||
hoveredImageSrc.value = img.getAttribute('src') ?? '';
|
||||
zoomBtnVisible.value = true;
|
||||
}
|
||||
|
||||
function isZoomBtn(el: EventTarget | null): boolean {
|
||||
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
|
||||
}
|
||||
|
||||
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
|
||||
const container = containerRef.value;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
// 1) target 本身或祖先链上是 img
|
||||
const direct =
|
||||
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
|
||||
if (direct && container.contains(direct)) {
|
||||
return direct;
|
||||
}
|
||||
// 2) 兜底:wangeditor 可能在图片上层叠了 resize/selection 遮罩,target 不是 img;用坐标穿透找
|
||||
if (typeof document.elementsFromPoint === 'function') {
|
||||
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
||||
for (const el of stack) {
|
||||
if (el.tagName === 'IMG' && container.contains(el)) {
|
||||
return el as HTMLImageElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onContainerMouseOver(e: MouseEvent) {
|
||||
if (isZoomBtn(e.target)) {
|
||||
cancelHideZoomBtn();
|
||||
return;
|
||||
}
|
||||
const img = findImageAtPoint(e);
|
||||
if (img) {
|
||||
cancelHideZoomBtn();
|
||||
positionZoomBtn(img);
|
||||
} else {
|
||||
scheduleHideZoomBtn();
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerMouseLeave() {
|
||||
scheduleHideZoomBtn();
|
||||
}
|
||||
|
||||
function onTextScroll() {
|
||||
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
|
||||
zoomBtnVisible.value = false;
|
||||
}
|
||||
|
||||
function openImageViewer() {
|
||||
if (!hoveredImageSrc.value) {
|
||||
return;
|
||||
}
|
||||
const urls = listImageSrcs(model.value);
|
||||
const idx = urls.indexOf(hoveredImageSrc.value);
|
||||
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
|
||||
viewerIndex.value = idx >= 0 ? idx : 0;
|
||||
viewerVisible.value = true;
|
||||
}
|
||||
|
||||
function closeImageViewer() {
|
||||
viewerVisible.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话级清理账本(富文本图片治标):
|
||||
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
|
||||
* - committed: commit() 调用后置 true,阻止后续 rollback / 卸载兜底重复删
|
||||
*
|
||||
* 真删时机:
|
||||
* - commit(): 扫当前 model HTML,删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
|
||||
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
|
||||
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
|
||||
*/
|
||||
interface RichTextSession {
|
||||
uploadedMap: Map<string, string>;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
const session = reactive<RichTextSession>({
|
||||
uploadedMap: new Map(),
|
||||
committed: false
|
||||
});
|
||||
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
excludeKeys: [
|
||||
// 视频组
|
||||
'group-video',
|
||||
'insertVideo',
|
||||
'uploadVideo',
|
||||
// 更多样式分组
|
||||
'group-more-style',
|
||||
// 图片:只允许本地上传,不允许插入网络图片 URL
|
||||
'insertImage',
|
||||
// 超链接:业务暂不需要
|
||||
'insertLink',
|
||||
'editLink',
|
||||
'unLink',
|
||||
'viewLink'
|
||||
]
|
||||
};
|
||||
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: props.placeholder,
|
||||
readOnly: props.disabled,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
maxFileSize: props.maxImageSizeMB * 1024 * 1024,
|
||||
allowedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp'],
|
||||
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||
const result = await uploadFile(file, props.uploadDirectory);
|
||||
|
||||
if (result.error || !result.data) {
|
||||
const msg = result.error?.response?.data?.msg || '图片上传失败';
|
||||
window.$message?.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 用永久代理路径塞 <img src>,不要用 result.data.url(24h 签名会过期)
|
||||
const { id, configId, path } = result.data;
|
||||
const proxyUrl = buildFileProxyUrl(configId, path);
|
||||
// 记录 url -> fileId,后续 commit/rollback 才知道删哪个
|
||||
session.uploadedMap.set(proxyUrl, id);
|
||||
insertFn(proxyUrl, file.name, proxyUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
value => {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
editor.disable();
|
||||
} else {
|
||||
editor.enable();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor;
|
||||
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
|
||||
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
|
||||
*/
|
||||
function extractImageUrls(html: string | null | undefined): Set<string> {
|
||||
const urls = new Set<string>();
|
||||
if (!html) {
|
||||
return urls;
|
||||
}
|
||||
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null = re.exec(html);
|
||||
while (match !== null) {
|
||||
urls.add(match[1]);
|
||||
match = re.exec(html);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** 按出现顺序去重列出当前 HTML 内所有 img src,给 ElImageViewer 用。 */
|
||||
function listImageSrcs(html: string | null | undefined): string[] {
|
||||
const list: string[] = [];
|
||||
if (!html) {
|
||||
return list;
|
||||
}
|
||||
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null = re.exec(html);
|
||||
while (match !== null) {
|
||||
if (!list.includes(match[1])) {
|
||||
list.push(match[1]);
|
||||
}
|
||||
match = re.exec(html);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 删除一批 fileId。fire-and-forget;单项失败仅 console.warn。 */
|
||||
async function deleteMany(ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled(
|
||||
ids.map(async id => {
|
||||
const { error } = await deleteFile(id);
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* 父组件在【打开弹层并填充 model 之后】调用。
|
||||
* 清空 uploadedMap 并重置 committed;HTML 里已有的图(编辑模式回显的)不进 uploadedMap,
|
||||
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
|
||||
*/
|
||||
initSession() {
|
||||
session.uploadedMap.clear();
|
||||
session.committed = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件在【业务保存成功后】调用。
|
||||
* 扫当前 model HTML:uploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
|
||||
*/
|
||||
async commit() {
|
||||
const currentUrls = extractImageUrls(model.value);
|
||||
const toDelete: string[] = [];
|
||||
session.uploadedMap.forEach((fileId, url) => {
|
||||
if (!currentUrls.has(url)) {
|
||||
toDelete.push(fileId);
|
||||
}
|
||||
});
|
||||
session.uploadedMap.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(toDelete);
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||||
* 删 uploadedMap 里所有项(整个会话回滚)。
|
||||
*/
|
||||
async rollback() {
|
||||
if (session.committed) {
|
||||
return;
|
||||
}
|
||||
const toDelete = Array.from(session.uploadedMap.values());
|
||||
session.uploadedMap.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(toDelete);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelHideZoomBtn();
|
||||
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||
textContainer?.removeEventListener('scroll', onTextScroll);
|
||||
|
||||
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||||
if (!session.committed) {
|
||||
const toDelete = Array.from(session.uploadedMap.values());
|
||||
session.uploadedMap.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
deleteMany(toDelete);
|
||||
}
|
||||
editorRef.value?.destroy();
|
||||
editorRef.value = undefined;
|
||||
});
|
||||
|
||||
/** 当 height 传 '100%' 或 'auto' 时启用「撑满父容器」模式 —— 父级必须有具体高度。 */
|
||||
const isAutoFill = computed(() => props.height === '100%' || props.height === 'auto');
|
||||
|
||||
const containerClass = computed(() => ({
|
||||
'business-rich-text-editor': true,
|
||||
'business-rich-text-editor--auto-fill': isAutoFill.value
|
||||
}));
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
if (isAutoFill.value) {
|
||||
return { flex: 1, minHeight: 0, overflowY: 'hidden' as const };
|
||||
}
|
||||
|
||||
return {
|
||||
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
||||
overflowY: 'hidden' as const
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
|
||||
<Toolbar
|
||||
class="business-rich-text-editor__toolbar"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
mode="default"
|
||||
/>
|
||||
<Editor
|
||||
v-model="model"
|
||||
class="business-rich-text-editor__editor"
|
||||
:style="editorStyle"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
<button
|
||||
v-show="zoomBtnVisible"
|
||||
type="button"
|
||||
class="business-rich-text-editor__zoom-btn"
|
||||
:style="zoomBtnStyle"
|
||||
title="预览图片"
|
||||
aria-label="预览图片"
|
||||
@click.stop="openImageViewer"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ElImageViewer
|
||||
v-if="viewerVisible"
|
||||
:url-list="viewerUrlList"
|
||||
:initial-index="viewerIndex"
|
||||
:z-index="3100"
|
||||
teleported
|
||||
hide-on-click-modal
|
||||
@close="closeImageViewer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.business-rich-text-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
&__toolbar {
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__editor {
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
&--auto-fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__zoom-btn {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */
|
||||
:deep(.w-e-modal),
|
||||
:deep(.w-e-drop-panel),
|
||||
:deep(.w-e-bar-divider),
|
||||
:deep(.w-e-hover-bar) {
|
||||
z-index: 3000 !important;
|
||||
}
|
||||
</style>
|
||||
88
src/components/custom/business-rich-text-view.vue
Normal file
88
src/components/custom/business-rich-text-view.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { sanitizeHtml } from '@/utils/sanitize';
|
||||
|
||||
defineOptions({ name: 'BusinessRichTextView' });
|
||||
|
||||
interface Props {
|
||||
value?: string | null;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: '',
|
||||
emptyText: '—'
|
||||
});
|
||||
|
||||
const safeHtml = computed(() => sanitizeHtml(props.value));
|
||||
const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+>/g, '').trim() === '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-rich-text-view">
|
||||
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.business-rich-text-view {
|
||||
width: 100%;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
|
||||
&__empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
&__content {
|
||||
:deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
:deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
padding: 6px 12px;
|
||||
margin: 0 0 8px;
|
||||
border-left: 3px solid var(--el-border-color);
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
:deep(table td),
|
||||
:deep(table th) {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,13 @@
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { ElButton, ElPopover } from 'element-plus';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
icon?: Component;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
};
|
||||
@@ -17,12 +18,20 @@ export default defineComponent({
|
||||
actions: {
|
||||
type: Array as PropType<BusinessTableAction[]>,
|
||||
required: true
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<'button' | 'icon'>,
|
||||
default: 'button'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
const directActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return props.actions;
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return props.actions;
|
||||
}
|
||||
@@ -31,6 +40,10 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const moreActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,21 +60,86 @@ export default defineComponent({
|
||||
await action.onClick();
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action => (
|
||||
function renderIcon(action: BusinessTableAction) {
|
||||
if (!action.icon) return null;
|
||||
|
||||
return h(action.icon, { class: 'business-table-action-icon' });
|
||||
}
|
||||
|
||||
function renderButtonAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIconAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
class="business-table-action-icon-button"
|
||||
aria-label={action.label}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
{renderIcon(action)}
|
||||
</ElButton>
|
||||
))}
|
||||
</ElTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMenuButton(action: BusinessTableAction) {
|
||||
if (props.variant === 'icon') {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__link"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
<span class="business-table-action-menu__item">
|
||||
{renderIcon(action)}
|
||||
<span>{action.label}</span>
|
||||
</span>
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action =>
|
||||
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||
)}
|
||||
|
||||
{moreActions.value.length > 0 && (
|
||||
<ElPopover
|
||||
@@ -74,32 +152,28 @@ export default defineComponent({
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton
|
||||
plain
|
||||
link={props.variant === 'icon'}
|
||||
plain={props.variant !== 'icon'}
|
||||
size="small"
|
||||
class="business-table-action-button"
|
||||
class={
|
||||
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||
}
|
||||
aria-label={$t('common.more')}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
{props.variant === 'icon' ? (
|
||||
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
),
|
||||
default: () => (
|
||||
<div class="business-table-action-menu">
|
||||
{moreActions.value.map(action => (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
))}
|
||||
{moreActions.value.map(action => renderMenuButton(action))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
920
src/components/custom/business-user-picker.vue
Normal file
920
src/components/custom/business-user-picker.vue
Normal file
@@ -0,0 +1,920 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
|
||||
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
|
||||
import { useChainSource } from './business-user-picker/composables/use-chain-source';
|
||||
import UserPickerTrigger from './business-user-picker/components/user-picker-trigger.vue';
|
||||
import IconEpOfficeBuilding from '~icons/ep/office-building';
|
||||
import IconEpUser from '~icons/ep/user';
|
||||
|
||||
defineOptions({ name: 'BusinessUserPicker' });
|
||||
|
||||
type Source = 'dept' | 'chain' | 'all';
|
||||
|
||||
interface Props {
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
sources?: Source[];
|
||||
multiple?: boolean;
|
||||
disabledUserIds?: readonly string[];
|
||||
excludeUserIds?: readonly string[];
|
||||
disabledLabel?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
dialogWidth?: string;
|
||||
confirmText?: string;
|
||||
triggerSize?: 'default' | 'small' | 'large';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sources: () => ['dept', 'chain', 'all'],
|
||||
multiple: false,
|
||||
disabledUserIds: () => [],
|
||||
excludeUserIds: () => [],
|
||||
disabledLabel: '',
|
||||
placeholder: '请选择用户',
|
||||
title: '选择用户',
|
||||
dialogWidth: '820px',
|
||||
confirmText: '',
|
||||
triggerSize: 'default',
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', value: string | string[] | null): void;
|
||||
(e: 'confirm', payload: { userIds: string[] }): void;
|
||||
(e: 'cancel'): void;
|
||||
}
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<string | string[] | null>({ default: null });
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const source = ref<Source>(props.sources[0] ?? 'all');
|
||||
const currentNodeId = ref<string | null>(null);
|
||||
const treeSearch = ref('');
|
||||
const userSearch = ref('');
|
||||
const hideAdded = ref(false);
|
||||
|
||||
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(String)));
|
||||
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(String)));
|
||||
|
||||
const selection = usePickerSelection(() => ({ multiple: props.multiple }));
|
||||
const deptSource = useDeptSource(
|
||||
() => props.userOptions,
|
||||
() => new Set(selection.selectedIds.value),
|
||||
() => disabledUserIdSet.value
|
||||
);
|
||||
const chainSource = useChainSource(
|
||||
() => new Set(selection.selectedIds.value),
|
||||
() => disabledUserIdSet.value
|
||||
);
|
||||
|
||||
const showTabs = computed(() => props.sources.length > 1);
|
||||
|
||||
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
|
||||
|
||||
const selectedUsers = computed(() =>
|
||||
selection.selectedIds.value
|
||||
.map(id => userByIdMap.value.get(id))
|
||||
.filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
|
||||
);
|
||||
|
||||
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
|
||||
|
||||
const visibleSelectedIds = computed(() => selection.selectedIds.value.slice(0, 4));
|
||||
const overflowSelectedCount = computed(() => Math.max(0, selection.size.value - 4));
|
||||
const overflowSelectedIds = computed(() => selection.selectedIds.value.slice(4));
|
||||
const overflowPopoverVisible = ref(false);
|
||||
const overflowReferenceEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function handleOverflowOutsideClick(e: MouseEvent) {
|
||||
if (!overflowPopoverVisible.value) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('.user-picker__overflow-popper')) return;
|
||||
if (target.closest('.el-popper')) return;
|
||||
if (overflowReferenceEl.value?.contains(target)) return;
|
||||
overflowPopoverVisible.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||
|
||||
function getUserById(uid: string) {
|
||||
return userByIdMap.value.get(uid);
|
||||
}
|
||||
|
||||
function visibleUserIds(): string[] {
|
||||
let pool: string[];
|
||||
if (source.value === 'all' || !currentNodeId.value) {
|
||||
pool = props.userOptions.map(u => String(u.id));
|
||||
} else if (source.value === 'dept') {
|
||||
const node = deptSource.findNode(deptSource.tree.value, currentNodeId.value);
|
||||
pool = node ? deptSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||
} else {
|
||||
const node = chainSource.findNode(chainSource.tree.value, currentNodeId.value);
|
||||
pool = node ? chainSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||
}
|
||||
return pool.filter(id => !excludeUserIdSet.value.has(id));
|
||||
}
|
||||
|
||||
const filteredUserIds = computed(() => {
|
||||
let ids = visibleUserIds();
|
||||
if (hideAdded.value) ids = ids.filter(id => !disabledUserIdSet.value.has(id));
|
||||
const kw = userSearch.value.trim().toLowerCase();
|
||||
if (kw) {
|
||||
ids = ids.filter(id => {
|
||||
const u = getUserById(id);
|
||||
if (!u) return false;
|
||||
return (
|
||||
u.nickname.toLowerCase().includes(kw) ||
|
||||
(u.username ?? '').toLowerCase().includes(kw) ||
|
||||
(u.deptName ?? '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
async function switchSource(next: Source) {
|
||||
if (source.value === next) return;
|
||||
source.value = next;
|
||||
currentNodeId.value = null;
|
||||
treeSearch.value = '';
|
||||
if (next === 'dept') await deptSource.ensureLoaded();
|
||||
else if (next === 'chain') await chainSource.ensureLoaded();
|
||||
}
|
||||
|
||||
function handleDeptNodeClick(data: Api.SystemManage.DeptSimple) {
|
||||
currentNodeId.value = deptSource.nodeKey(data);
|
||||
}
|
||||
|
||||
function handleChainNodeClick(data: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
currentNodeId.value = chainSource.nodeKey(data);
|
||||
}
|
||||
|
||||
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
|
||||
if (!props.multiple) return;
|
||||
const ids = deptSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||
const state = deptSource.getNodeCheckState(node);
|
||||
if (state === 'all') selection.removeMany(ids);
|
||||
else selection.addMany(ids);
|
||||
}
|
||||
|
||||
function toggleChainCheck(node: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
if (!props.multiple) return;
|
||||
const ids = chainSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||
const state = chainSource.getNodeCheckState(node);
|
||||
if (state === 'all') selection.removeMany(ids);
|
||||
else selection.addMany(ids);
|
||||
}
|
||||
|
||||
function toggleUser(uid: string) {
|
||||
if (disabledUserIdSet.value.has(uid)) return;
|
||||
selection.toggle(uid);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selection.clear(lockedSelectedIds.value);
|
||||
}
|
||||
|
||||
function clearUserFilter() {
|
||||
userSearch.value = '';
|
||||
hideAdded.value = false;
|
||||
}
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (!props.multiple) return !selection.selectedIds.value.length;
|
||||
return selection.size.value === 0;
|
||||
});
|
||||
|
||||
const resolvedConfirmText = computed(() => {
|
||||
if (props.confirmText) return props.confirmText;
|
||||
if (!props.multiple) return '确定';
|
||||
return `确定(${selection.size.value})`;
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled.value) return;
|
||||
const value = selection.commit();
|
||||
model.value = value;
|
||||
emit('change', value);
|
||||
emit('confirm', { userIds: selection.selectedIds.value });
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (value) {
|
||||
treeSearch.value = '';
|
||||
userSearch.value = '';
|
||||
hideAdded.value = false;
|
||||
currentNodeId.value = null;
|
||||
source.value = props.sources[0] ?? 'all';
|
||||
selection.reset(model.value);
|
||||
if (source.value === 'dept') await deptSource.ensureLoaded();
|
||||
else if (source.value === 'chain') await chainSource.ensureLoaded();
|
||||
await nextTick();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-user-picker">
|
||||
<slot name="trigger" :open="openDialog" :selected-users="selectedUsers" :disabled="disabled">
|
||||
<UserPickerTrigger
|
||||
:selected-users="selectedUsers"
|
||||
:placeholder="placeholder"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:size="triggerSize"
|
||||
@open="openDialog"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:width="dialogWidth"
|
||||
max-body-height="540px"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:confirm-text="resolvedConfirmText"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="user-picker">
|
||||
<div v-if="showTabs" class="user-picker__tabs">
|
||||
<button
|
||||
v-for="tab in sources"
|
||||
:key="tab"
|
||||
class="user-picker__tab"
|
||||
:class="{ 'is-active': source === tab }"
|
||||
type="button"
|
||||
@click="switchSource(tab)"
|
||||
>
|
||||
{{ tab === 'dept' ? '部门' : tab === 'chain' ? '团队' : '全部用户' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-picker__picker" :class="{ 'is-single': source === 'all' }">
|
||||
<div v-if="source !== 'all'" class="user-picker__col user-picker__col--tree">
|
||||
<div class="user-picker__col-head">{{ source === 'dept' ? '部门' : '团队' }}</div>
|
||||
<div class="user-picker__search">
|
||||
<ElInput
|
||||
v-model="treeSearch"
|
||||
size="small"
|
||||
clearable
|
||||
:placeholder="source === 'dept' ? '搜索部门…' : '搜索成员…'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-loading="source === 'dept' ? deptSource.loading.value : chainSource.loading.value"
|
||||
class="user-picker__col-body"
|
||||
>
|
||||
<ElTree
|
||||
v-if="source === 'dept'"
|
||||
:data="deptSource.filterByKeyword(treeSearch)"
|
||||
:props="deptSource.treeProps.value"
|
||||
node-key="id"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="true"
|
||||
:indent="14"
|
||||
class="user-picker__tree"
|
||||
@node-click="handleDeptNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === String(data.id) }">
|
||||
<span
|
||||
v-if="multiple"
|
||||
class="user-picker__node-check"
|
||||
:class="{
|
||||
'is-checked': deptSource.getNodeCheckState(data) === 'all',
|
||||
'is-partial': deptSource.getNodeCheckState(data) === 'partial'
|
||||
}"
|
||||
@click.stop="toggleDeptCheck(data)"
|
||||
/>
|
||||
<IconEpOfficeBuilding class="user-picker__node-icon" />
|
||||
<span class="user-picker__node-label">{{ data.name }}</span>
|
||||
<span v-if="deptSource.getMetaText(data)" class="user-picker__node-meta">
|
||||
{{ deptSource.getMetaText(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
<ElTree
|
||||
v-else
|
||||
:data="chainSource.filterByKeyword(treeSearch)"
|
||||
:props="chainSource.treeProps.value"
|
||||
node-key="userId"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="true"
|
||||
:indent="14"
|
||||
class="user-picker__tree"
|
||||
@node-click="handleChainNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === chainSource.nodeKey(data) }">
|
||||
<span
|
||||
v-if="multiple"
|
||||
class="user-picker__node-check"
|
||||
:class="{
|
||||
'is-checked': chainSource.getNodeCheckState(data) === 'all',
|
||||
'is-partial': chainSource.getNodeCheckState(data) === 'partial'
|
||||
}"
|
||||
@click.stop="toggleChainCheck(data)"
|
||||
/>
|
||||
<IconEpUser class="user-picker__node-icon" />
|
||||
<span class="user-picker__node-label">{{ data.userNickname }}</span>
|
||||
<span v-if="chainSource.getMetaText(data)" class="user-picker__node-meta">
|
||||
{{ chainSource.getMetaText(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-picker__col user-picker__col--users">
|
||||
<div class="user-picker__col-head user-picker__col-head--user">
|
||||
<span>
|
||||
候选用户(
|
||||
<span>{{ filteredUserIds.length }}</span>
|
||||
人)
|
||||
</span>
|
||||
<label v-if="multiple" class="user-picker__hide-added">
|
||||
<ElCheckbox v-model="hideAdded">隐藏已添加</ElCheckbox>
|
||||
</label>
|
||||
</div>
|
||||
<div class="user-picker__search">
|
||||
<ElInput
|
||||
v-model="userSearch"
|
||||
size="small"
|
||||
clearable
|
||||
:placeholder="source === 'all' ? '搜索用户名 / 部门…' : '搜索用户名…'"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-picker__col-body">
|
||||
<div v-if="!filteredUserIds.length" class="user-picker__empty">
|
||||
该节点下没有匹配用户
|
||||
<button
|
||||
v-if="userSearch || hideAdded"
|
||||
type="button"
|
||||
class="user-picker__link user-picker__empty-action"
|
||||
@click="clearUserFilter"
|
||||
>
|
||||
清除筛选条件
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="uid in filteredUserIds"
|
||||
:key="uid"
|
||||
class="user-picker__user-row"
|
||||
:class="{
|
||||
'is-disabled': disabledUserIdSet.has(uid),
|
||||
'is-selected': !multiple && selection.has(uid)
|
||||
}"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
<span v-if="multiple" class="user-picker__node-check" :class="{ 'is-checked': selection.has(uid) }" />
|
||||
<span class="user-picker__user-avatar">{{ (getUserById(uid)?.nickname ?? '?').slice(0, 1) }}</span>
|
||||
<div class="user-picker__user-main">
|
||||
<div class="user-picker__user-name">{{ getUserById(uid)?.nickname }}</div>
|
||||
</div>
|
||||
<span v-if="disabledUserIdSet.has(uid) && disabledLabel" class="user-picker__user-tag">
|
||||
{{ disabledLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="multiple" class="user-picker__selected">
|
||||
<div class="user-picker__selected-head">
|
||||
<span>
|
||||
已选
|
||||
<strong>{{ selection.size.value }}</strong>
|
||||
人
|
||||
</span>
|
||||
<button
|
||||
v-if="selection.size.value > lockedSelectedIds.length"
|
||||
type="button"
|
||||
class="user-picker__link user-picker__link--danger"
|
||||
@click="clearAll"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selection.size.value === 0" class="user-picker__selected-empty">从左侧勾选用户后会出现在这里</div>
|
||||
<div v-else class="user-picker__chips">
|
||||
<span v-for="uid in visibleSelectedIds" :key="uid" class="user-picker__chip">
|
||||
<span class="user-picker__chip-name">
|
||||
{{ getUserById(uid)?.nickname }}
|
||||
<ElTooltip v-if="disabledUserIdSet.has(uid) && disabledLabel" :content="disabledLabel" placement="top">
|
||||
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<button
|
||||
v-if="!disabledUserIdSet.has(uid)"
|
||||
type="button"
|
||||
class="user-picker__chip-x"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<ElPopover
|
||||
v-if="overflowSelectedCount > 0"
|
||||
:visible="overflowPopoverVisible"
|
||||
placement="top-end"
|
||||
:width="360"
|
||||
popper-class="user-picker__overflow-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
ref="overflowReferenceEl"
|
||||
type="button"
|
||||
class="user-picker__chip-more"
|
||||
@click="overflowPopoverVisible = !overflowPopoverVisible"
|
||||
>
|
||||
+{{ overflowSelectedCount }} 更多
|
||||
</button>
|
||||
</template>
|
||||
<div class="user-picker__overflow-head">
|
||||
<span>
|
||||
另外
|
||||
<strong>{{ overflowSelectedCount }}</strong>
|
||||
人
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-picker__overflow-chips">
|
||||
<span v-for="uid in overflowSelectedIds" :key="uid" class="user-picker__chip">
|
||||
<span class="user-picker__chip-name">
|
||||
{{ getUserById(uid)?.nickname }}
|
||||
<ElTooltip
|
||||
v-if="disabledUserIdSet.has(uid) && disabledLabel"
|
||||
:content="disabledLabel"
|
||||
placement="top"
|
||||
>
|
||||
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<button
|
||||
v-if="!disabledUserIdSet.has(uid)"
|
||||
type="button"
|
||||
class="user-picker__chip-x"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-user-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* picker 内容上下贴满,标准 body padding 显得空——仅在含本组件的 dialog 上收紧 */
|
||||
:deep(.business-form-dialog__body:has(.user-picker)) {
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.user-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-picker__tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker__tab {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.user-picker__tab.is-active {
|
||||
color: var(--el-color-primary);
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-picker__picker {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 12px;
|
||||
height: min(280px, 44vh);
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.user-picker__picker.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-picker__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-picker__col-head {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
background: #fafbfc;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__col-head--user {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-picker__col-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-picker__search {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker__tree {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
padding-right: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__content:hover) {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__expand-icon) {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__expand-icon.is-leaf) {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.user-picker__node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node.is-active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-picker__node-check {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 2px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__node-check:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-checked {
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
width: 3px;
|
||||
height: 7px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1px 1px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-partial {
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-partial::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
margin: -1px 0 0 -4px;
|
||||
background: #fff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.user-picker__node-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__node.is-active .user-picker__node-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker__node-meta {
|
||||
flex-shrink: 0;
|
||||
padding-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.user-picker__node.is-active .user-picker__node-meta {
|
||||
color: var(--el-color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.user-picker__user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-picker__user-row:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-selected .user-picker__user-name {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-picker__user-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-picker__user-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-picker__user-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker__user-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--el-color-warning-light-7);
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
|
||||
.user-picker__empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-picker__hide-added {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__empty-action {
|
||||
display: block;
|
||||
margin: 6px auto 0;
|
||||
}
|
||||
|
||||
.user-picker__selected {
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-picker__selected-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__selected-head strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.user-picker__selected-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.user-picker__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-darker);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__chip-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-picker__chip-lock {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-picker__chip-x {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-picker__chip-x:hover {
|
||||
background: var(--el-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-picker__chip-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__chip-more:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.user-picker__overflow-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__overflow-head strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-picker__overflow-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-picker__link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-picker__link--danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.user-picker__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'UserPickerTrigger' });
|
||||
|
||||
interface Props {
|
||||
selectedUsers: Api.SystemManage.UserSimple[];
|
||||
placeholder: string;
|
||||
multiple: boolean;
|
||||
disabled: boolean;
|
||||
size: 'default' | 'small' | 'large';
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{ (e: 'open'): void }>();
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!props.selectedUsers.length) return '';
|
||||
if (!props.multiple) return props.selectedUsers[0]?.nickname ?? '';
|
||||
const head = props.selectedUsers
|
||||
.slice(0, 2)
|
||||
.map(u => u.nickname)
|
||||
.join('、');
|
||||
const rest = props.selectedUsers.length - 2;
|
||||
return rest > 0 ? `${head} +${rest}` : head;
|
||||
});
|
||||
|
||||
const sizeClass = computed(() => `is-${props.size}`);
|
||||
|
||||
function handleClick() {
|
||||
if (props.disabled) return;
|
||||
emit('open');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="user-picker-trigger"
|
||||
:class="[sizeClass, { 'is-disabled': disabled }]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
@keydown.enter.prevent="handleClick"
|
||||
@keydown.space.prevent="handleClick"
|
||||
>
|
||||
<span v-if="displayText" class="user-picker-trigger__text">{{ displayText }}</span>
|
||||
<span v-else class="user-picker-trigger__placeholder">{{ placeholder }}</span>
|
||||
<span class="user-picker-trigger__suffix">
|
||||
<icon-ep:arrow-down />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-picker-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 30px 0 11px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
font-size: var(--el-font-size-base);
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-small {
|
||||
min-height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-large {
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-picker-trigger:hover:not(.is-disabled) {
|
||||
border-color: var(--el-border-color-hover);
|
||||
}
|
||||
|
||||
.user-picker-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-disabled {
|
||||
background: var(--el-disabled-bg-color);
|
||||
color: var(--el-disabled-text-color);
|
||||
cursor: not-allowed;
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker-trigger__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker-trigger__placeholder {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker-trigger__suffix {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchGetUserManagementRelationTree } from '@/service/api';
|
||||
import type { TreeCheckState } from './use-dept-source';
|
||||
|
||||
type ChainNode = Api.SystemManage.UserManagementRelationTreeRespVO;
|
||||
|
||||
export function useChainSource(selectedIds: () => Set<string>, disabledUserIdSet: () => Set<string>) {
|
||||
const tree = ref<ChainNode[]>([]);
|
||||
const loading = ref(false);
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (loaded) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchGetUserManagementRelationTree({ fromUserIndex: false });
|
||||
tree.value = data ?? [];
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(node: ChainNode): string {
|
||||
return node.id ?? `chain_${node.userId}`;
|
||||
}
|
||||
|
||||
function getNodeUserIds(node: ChainNode): string[] {
|
||||
const ids = new Set<string>([String(node.userId)]);
|
||||
if (node.children) {
|
||||
for (const c of node.children) {
|
||||
for (const id of getNodeUserIds(c)) ids.add(id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function getNodeCheckState(node: ChainNode): TreeCheckState {
|
||||
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||
if (!ids.length) return 'none';
|
||||
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||
if (sel === 0) return 'none';
|
||||
if (sel === ids.length) return 'all';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function findNode(list: ChainNode[], key: string): ChainNode | null {
|
||||
for (const n of list) {
|
||||
if (nodeKey(n) === key) return n;
|
||||
if (n.children) {
|
||||
const r = findNode(n.children, key);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchKeyword(node: ChainNode, kw: string): boolean {
|
||||
if (!kw) return true;
|
||||
if (node.userNickname.toLowerCase().includes(kw)) return true;
|
||||
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterByKeyword(kw: string) {
|
||||
const lower = kw.trim().toLowerCase();
|
||||
if (!lower) return tree.value;
|
||||
return tree.value.filter(n => matchKeyword(n, lower));
|
||||
}
|
||||
|
||||
function getMetaText(node: ChainNode): string {
|
||||
const total = getNodeUserIds(node).length;
|
||||
return total > 1 ? `${total} 人` : '';
|
||||
}
|
||||
|
||||
const treeProps = computed(() => ({ children: 'children', label: 'userNickname' }) as const);
|
||||
|
||||
return {
|
||||
tree,
|
||||
loading,
|
||||
treeProps,
|
||||
ensureLoaded,
|
||||
getNodeUserIds,
|
||||
getNodeCheckState,
|
||||
findNode,
|
||||
filterByKeyword,
|
||||
getMetaText,
|
||||
nodeKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchGetDeptSimpleList } from '@/service/api';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
|
||||
export type TreeCheckState = 'none' | 'partial' | 'all';
|
||||
|
||||
export function useDeptSource(
|
||||
userOptions: () => Api.SystemManage.UserSimple[],
|
||||
selectedIds: () => Set<string>,
|
||||
disabledUserIdSet: () => Set<string>
|
||||
) {
|
||||
const tree = ref<Api.SystemManage.DeptSimple[]>([]);
|
||||
const loading = ref(false);
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (loaded) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchGetDeptSimpleList();
|
||||
tree.value = data ? buildMenuTree(data) : [];
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||
const ids: string[] = [String(node.id)];
|
||||
if (node.children) {
|
||||
for (const c of node.children) ids.push(...collectDeptIds(c));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getNodeUserIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||
const deptIds = new Set(collectDeptIds(node));
|
||||
return userOptions()
|
||||
.filter(u => u.deptId !== null && u.deptId !== undefined && deptIds.has(String(u.deptId)))
|
||||
.map(u => String(u.id));
|
||||
}
|
||||
|
||||
function getNodeCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
|
||||
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||
if (!ids.length) return 'none';
|
||||
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||
if (sel === 0) return 'none';
|
||||
if (sel === ids.length) return 'all';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function findNode(list: Api.SystemManage.DeptSimple[], key: string): Api.SystemManage.DeptSimple | null {
|
||||
for (const n of list) {
|
||||
if (String(n.id) === key) return n;
|
||||
if (n.children) {
|
||||
const r = findNode(n.children, key);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchKeyword(node: Api.SystemManage.DeptSimple, kw: string): boolean {
|
||||
if (!kw) return true;
|
||||
if (node.name.toLowerCase().includes(kw)) return true;
|
||||
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterByKeyword(kw: string) {
|
||||
const lower = kw.trim().toLowerCase();
|
||||
if (!lower) return tree.value;
|
||||
return tree.value.filter(n => matchKeyword(n, lower));
|
||||
}
|
||||
|
||||
function getMetaText(node: Api.SystemManage.DeptSimple): string {
|
||||
const total = getNodeUserIds(node).length;
|
||||
return total > 0 ? `${total} 人` : '';
|
||||
}
|
||||
|
||||
function nodeKey(node: Api.SystemManage.DeptSimple): string {
|
||||
return String(node.id);
|
||||
}
|
||||
|
||||
const treeProps = computed(() => ({ children: 'children', label: 'name' }) as const);
|
||||
|
||||
return {
|
||||
tree,
|
||||
loading,
|
||||
treeProps,
|
||||
ensureLoaded,
|
||||
getNodeUserIds,
|
||||
getNodeCheckState,
|
||||
findNode,
|
||||
filterByKeyword,
|
||||
getMetaText,
|
||||
nodeKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export interface PickerSelectionOptions {
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export function usePickerSelection(options: () => PickerSelectionOptions) {
|
||||
const multiSet = ref<Set<string>>(new Set());
|
||||
const singleId = ref<string | null>(null);
|
||||
|
||||
const multiple = computed(() => options().multiple);
|
||||
|
||||
function has(userId: string): boolean {
|
||||
if (multiple.value) return multiSet.value.has(userId);
|
||||
return singleId.value === userId;
|
||||
}
|
||||
|
||||
function toggle(userId: string) {
|
||||
if (multiple.value) {
|
||||
if (multiSet.value.has(userId)) multiSet.value.delete(userId);
|
||||
else multiSet.value.add(userId);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
} else {
|
||||
singleId.value = singleId.value === userId ? null : userId;
|
||||
}
|
||||
}
|
||||
|
||||
function addMany(userIds: readonly string[]) {
|
||||
if (!multiple.value) {
|
||||
singleId.value = userIds[0] ?? singleId.value;
|
||||
return;
|
||||
}
|
||||
for (const id of userIds) multiSet.value.add(id);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
}
|
||||
|
||||
function removeMany(userIds: readonly string[]) {
|
||||
if (!multiple.value) {
|
||||
if (singleId.value && userIds.includes(singleId.value)) singleId.value = null;
|
||||
return;
|
||||
}
|
||||
for (const id of userIds) multiSet.value.delete(id);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
}
|
||||
|
||||
function clear(preserveIds?: readonly string[]) {
|
||||
const keep = new Set((preserveIds ?? []).map(String));
|
||||
if (multiple.value) {
|
||||
const next = new Set<string>();
|
||||
for (const id of multiSet.value) {
|
||||
if (keep.has(id)) next.add(id);
|
||||
}
|
||||
multiSet.value = next;
|
||||
} else if (singleId.value && !keep.has(singleId.value)) singleId.value = null;
|
||||
}
|
||||
|
||||
function reset(initial: string | string[] | null | undefined) {
|
||||
if (multiple.value) {
|
||||
const ids = Array.isArray(initial) ? initial.map(String) : [];
|
||||
multiSet.value = new Set(ids);
|
||||
} else {
|
||||
singleId.value = typeof initial === 'string' ? initial : null;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedIds = computed<string[]>(() => {
|
||||
if (multiple.value) return [...multiSet.value];
|
||||
return singleId.value ? [singleId.value] : [];
|
||||
});
|
||||
|
||||
const size = computed(() => selectedIds.value.length);
|
||||
|
||||
function commit(): string | string[] | null {
|
||||
if (multiple.value) return [...multiSet.value];
|
||||
return singleId.value;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
size,
|
||||
has,
|
||||
toggle,
|
||||
addMany,
|
||||
removeMany,
|
||||
clear,
|
||||
reset,
|
||||
commit
|
||||
};
|
||||
}
|
||||
131
src/components/custom/business-user-select.vue
Normal file
131
src/components/custom/business-user-select.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'BusinessUserSelect' });
|
||||
|
||||
interface Props {
|
||||
options: Api.SystemManage.UserSimple[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
disabledUserIds?: readonly string[];
|
||||
excludeUserIds?: readonly string[];
|
||||
disabledLabel?: string;
|
||||
noDataText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择用户',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
disabledUserIds: () => [],
|
||||
excludeUserIds: () => [],
|
||||
disabledLabel: '',
|
||||
noDataText: ''
|
||||
});
|
||||
|
||||
const model = defineModel<string | null>('modelValue', {
|
||||
default: null
|
||||
});
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(id => String(id))));
|
||||
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(id => String(id))));
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
|
||||
const options = props.options.filter(item => !excludeUserIdSet.value.has(String(item.id)));
|
||||
|
||||
if (!keyword) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter(item => {
|
||||
const searchText = [item.nickname, item.username, item.deptName, item.id]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase();
|
||||
|
||||
return searchText.includes(keyword);
|
||||
});
|
||||
});
|
||||
|
||||
function handleFilter(value: string) {
|
||||
searchKeyword.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="model"
|
||||
class="w-full"
|
||||
filterable
|
||||
:filter-method="handleFilter"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:no-data-text="noDataText || undefined"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
:disabled="disabledUserIdSet.has(String(item.id))"
|
||||
>
|
||||
<div class="business-user-select__option">
|
||||
<span class="business-user-select__name">{{ item.nickname }}</span>
|
||||
<span class="business-user-select__suffix">
|
||||
<ElTag
|
||||
v-if="disabledLabel && disabledUserIdSet.has(String(item.id))"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="light"
|
||||
disable-transitions
|
||||
>
|
||||
{{ disabledLabel }}
|
||||
</ElTag>
|
||||
<span v-if="item.deptName || item.username" class="business-user-select__meta">
|
||||
{{ [item.username, item.deptName].filter(Boolean).join(' · ') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-user-select__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.business-user-select__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.business-user-select__suffix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
max-width: 58%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.business-user-select__meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
134
src/components/custom/dict-select.vue
Normal file
134
src/components/custom/dict-select.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const ensuredEmptyDictCodes = new Set<string>();
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
onlyEnabled?: boolean;
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||
showRemark?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
filterable: false,
|
||||
onlyEnabled: true,
|
||||
multiple: false,
|
||||
collapseTags: false,
|
||||
collapseTagsTooltip: false,
|
||||
showRemark: false
|
||||
});
|
||||
|
||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const dictStore = useDictStore();
|
||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||
|
||||
const dictOptions = computed(() => {
|
||||
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
||||
return source.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
colorType: item.colorType ?? null,
|
||||
remark: item.remark ?? null
|
||||
}));
|
||||
});
|
||||
|
||||
// 单选时取当前选中项的 colorType,用于触发器 prefix 色块
|
||||
const selectedColorType = computed<string | null>(() => {
|
||||
if (props.multiple) return null;
|
||||
const value = model.value;
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||
async ([dictCode, optionCount, initialized, loading]) => {
|
||||
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensuredEmptyDictCodes.add(dictCode);
|
||||
await dictStore.ensureDictData(dictCode, true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="model"
|
||||
class="dict-select w-full"
|
||||
:placeholder="props.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:clearable="props.clearable"
|
||||
:filterable="props.filterable"
|
||||
:multiple="props.multiple"
|
||||
:collapse-tags="props.collapseTags"
|
||||
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
||||
>
|
||||
<template v-if="selectedColorType" #prefix>
|
||||
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
|
||||
</template>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||
<span class="dict-select__option">
|
||||
<span
|
||||
v-if="item.colorType"
|
||||
class="dict-select__color-dot dict-select__color-dot--option"
|
||||
:style="{ background: item.colorType }"
|
||||
/>
|
||||
<span class="dict-select__option-label">{{ item.label }}</span>
|
||||
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
|
||||
</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dict-select__color-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dict-select__color-dot--option {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option-label {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dict-select__option-remark {
|
||||
margin-left: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
95
src/components/custom/dict-tag.vue
Normal file
95
src/components/custom/dict-tag.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictText from './dict-text.vue';
|
||||
|
||||
defineOptions({ name: 'DictTag' });
|
||||
|
||||
type DictValue = string | number;
|
||||
type DictTagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
type DictTagEffect = 'dark' | 'light' | 'plain';
|
||||
type DictTagSize = 'large' | 'default' | 'small';
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
value?: DictValue | DictValue[] | null;
|
||||
fallback?: string;
|
||||
separator?: string;
|
||||
onlyEnabled?: boolean;
|
||||
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
|
||||
type?: DictTagType;
|
||||
effect?: DictTagEffect;
|
||||
size?: DictTagSize;
|
||||
round?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: null,
|
||||
fallback: '--',
|
||||
separator: ' / ',
|
||||
onlyEnabled: false,
|
||||
type: undefined,
|
||||
effect: 'light',
|
||||
size: 'default',
|
||||
round: false
|
||||
});
|
||||
|
||||
const { getItem } = useDict(() => props.dictCode);
|
||||
|
||||
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
|
||||
const autoColorType = computed<string | null>(() => {
|
||||
if (Array.isArray(props.value)) return null;
|
||||
if (props.value === null || props.value === undefined || props.value === '') return null;
|
||||
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
|
||||
});
|
||||
|
||||
// props.type 优先(向后兼容);其次字典 colorType(hex);都没有时回落到原生 ElTag 默认
|
||||
const hexColor = computed(() => (props.type ? null : autoColorType.value));
|
||||
|
||||
const tagStyle = computed<Record<string, string> | null>(() => {
|
||||
if (!hexColor.value) return null;
|
||||
// light 效果:浅底 + 主色字 + 中浅边;plain/dark 同样的色调思路,仅明度差异
|
||||
const fg = hexColor.value;
|
||||
if (props.effect === 'dark') {
|
||||
return {
|
||||
color: '#fff',
|
||||
background: fg,
|
||||
borderColor: fg
|
||||
};
|
||||
}
|
||||
if (props.effect === 'plain') {
|
||||
return {
|
||||
color: fg,
|
||||
background: 'transparent',
|
||||
borderColor: `color-mix(in srgb, ${fg} 50%, white)`
|
||||
};
|
||||
}
|
||||
// light(默认)
|
||||
return {
|
||||
color: fg,
|
||||
background: `color-mix(in srgb, ${fg} 12%, white)`,
|
||||
borderColor: `color-mix(in srgb, ${fg} 30%, white)`
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag
|
||||
:type="props.type"
|
||||
:effect="props.effect"
|
||||
:size="props.size"
|
||||
:round="props.round"
|
||||
:style="tagStyle ?? undefined"
|
||||
>
|
||||
<DictText
|
||||
:dict-code="props.dictCode"
|
||||
:value="props.value"
|
||||
:fallback="props.fallback"
|
||||
:separator="props.separator"
|
||||
:only-enabled="props.onlyEnabled"
|
||||
tag="span"
|
||||
/>
|
||||
</ElTag>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
53
src/components/custom/dict-text.vue
Normal file
53
src/components/custom/dict-text.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({
|
||||
name: 'DictText',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
type DictValue = string | number;
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
value?: DictValue | DictValue[] | null;
|
||||
fallback?: string;
|
||||
separator?: string;
|
||||
onlyEnabled?: boolean;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: null,
|
||||
fallback: '--',
|
||||
separator: ' / ',
|
||||
onlyEnabled: false,
|
||||
tag: 'span'
|
||||
});
|
||||
|
||||
const { getLabel, getLabels } = useDict(() => props.dictCode);
|
||||
|
||||
const text = computed(() => {
|
||||
if (Array.isArray(props.value)) {
|
||||
return getLabels(props.value, {
|
||||
fallback: props.fallback,
|
||||
separator: props.separator,
|
||||
onlyEnabled: props.onlyEnabled
|
||||
});
|
||||
}
|
||||
|
||||
return getLabel(props.value, {
|
||||
fallback: props.fallback,
|
||||
onlyEnabled: props.onlyEnabled
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="props.tag" v-bind="$attrs">
|
||||
{{ text }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -2,6 +2,13 @@
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'LookForward' });
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
|
||||
<SvgIcon local-icon="expectation" />
|
||||
</div>
|
||||
<slot>
|
||||
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3>
|
||||
<h3 class="text-28px text-primary font-500">{{ title ?? $t('common.lookForward') }}</h3>
|
||||
</slot>
|
||||
<slot name="subtitle">
|
||||
<p v-if="subtitle" class="text-14px text-base-text op-65">{{ subtitle }}</p>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
44
src/components/custom/readonly-field.vue
Normal file
44
src/components/custom/readonly-field.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ReadonlyField' });
|
||||
|
||||
interface Props {
|
||||
value?: string | number | null;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: '',
|
||||
placeholder: '--'
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.value === null || props.value === undefined || props.value === '') {
|
||||
return props.placeholder;
|
||||
}
|
||||
return String(props.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="readonly-field">
|
||||
{{ displayValue }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readonly-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
color: rgb(51 65 85 / 96%);
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
329
src/components/custom/table-search-fields.vue
Normal file
329
src/components/custom/table-search-fields.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import { ElButton, ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
|
||||
import DictSelect from './dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'TableSearchFields' });
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface SearchField {
|
||||
/** 字段键名 */
|
||||
key: string;
|
||||
/** 字段标签 */
|
||||
label: string;
|
||||
/** 字段类型 */
|
||||
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
|
||||
/** 占位列数,默认 1 */
|
||||
span?: number;
|
||||
/** select 类型的选项 */
|
||||
options?: Option[];
|
||||
/** dict 类型的字典编码 */
|
||||
dictCode?: string;
|
||||
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急") */
|
||||
showRemark?: boolean;
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string;
|
||||
/** select 类型的自定义选项渲染函数 */
|
||||
renderOption?: (option: Option) => VNode | VNode[] | string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 绑定表单数据对象 */
|
||||
modelValue: Record<string, any>;
|
||||
/** 查询字段定义数组 */
|
||||
fields: SearchField[];
|
||||
/** 每行格子数(按钮占 1 格) */
|
||||
columns: number;
|
||||
/** 表单标签宽度 */
|
||||
labelWidth?: string | number;
|
||||
/** 格子间距 */
|
||||
gutter?: number;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
labelWidth: 80,
|
||||
gutter: 16,
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void;
|
||||
(e: 'reset'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 折叠/展开状态
|
||||
const expanded = ref(false);
|
||||
|
||||
// 是否需要折叠(字段数 > columns - 1)
|
||||
const needsCollapse = computed(() => props.fields.length > props.columns - 1);
|
||||
|
||||
// 第一行字段数(留一个位置给按钮)
|
||||
const firstRowFieldCount = computed(() => props.columns - 1);
|
||||
|
||||
// 计算第一行字段
|
||||
const firstRowFields = computed(() => {
|
||||
if (expanded.value || !needsCollapse.value) {
|
||||
return props.fields.slice(0, firstRowFieldCount.value);
|
||||
}
|
||||
return props.fields.slice(0, firstRowFieldCount.value);
|
||||
});
|
||||
|
||||
// 计算后续行字段(用于展开后显示)
|
||||
const remainingFields = computed(() => {
|
||||
if (expanded.value || !needsCollapse.value) {
|
||||
return props.fields.slice(firstRowFieldCount.value);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const firstRowButtonSpan = computed(() => {
|
||||
return Math.floor(24 / props.columns);
|
||||
});
|
||||
|
||||
// 计算第一行字段的 span(字段和按钮区保持同一列宽)
|
||||
const firstRowFieldSpan = computed(() => {
|
||||
return firstRowButtonSpan.value;
|
||||
});
|
||||
|
||||
// 计算每个字段的 span(用于后续行)
|
||||
const fieldSpan = computed(() => {
|
||||
return Math.floor(24 / props.columns);
|
||||
});
|
||||
|
||||
// 字段不足时补足首行空列,确保按钮区始终落在 columns 定义的最后一格。
|
||||
const firstRowPlaceholderSpan = computed(() => {
|
||||
const emptySlotCount = Math.max(props.columns - 1 - firstRowFields.value.length, 0);
|
||||
return emptySlotCount * fieldSpan.value;
|
||||
});
|
||||
|
||||
function handleToggle() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<ElForm :model="props.modelValue" :label-width="props.labelWidth" @submit.prevent @keyup.enter="handleSearch">
|
||||
<!-- 第一行:fields + 按钮 -->
|
||||
<ElRow :gutter="props.gutter">
|
||||
<ElCol
|
||||
v-for="field in firstRowFields"
|
||||
:key="field.key"
|
||||
class="table-search-fields__col"
|
||||
:span="firstRowFieldSpan"
|
||||
>
|
||||
<ElFormItem :label="field.label">
|
||||
<ElInput
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
>
|
||||
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||
<template v-if="field.renderOption" #default>
|
||||
<component :is="field.renderOption(opt)" />
|
||||
</template>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="date"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="daterange"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<DictSelect
|
||||
v-else-if="field.type === 'dict'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
<ElCol
|
||||
v-if="firstRowPlaceholderSpan > 0"
|
||||
class="table-search-fields__col table-search-fields__placeholder-col"
|
||||
:span="firstRowPlaceholderSpan"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<ElCol class="table-search-fields__col table-search-fields__action-col" :span="firstRowButtonSpan">
|
||||
<ElFormItem class="table-search-fields__actions" label-width="0">
|
||||
<ElButton
|
||||
v-if="needsCollapse"
|
||||
circle
|
||||
:title="expanded ? '收起' : '展开'"
|
||||
:aria-label="expanded ? '收起查询条件' : '展开查询条件'"
|
||||
:disabled="props.disabled"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<icon-mdi-chevron-double-up v-if="expanded" />
|
||||
<icon-mdi-chevron-double-down v-else />
|
||||
</ElButton>
|
||||
<ElButton :disabled="props.disabled" @click="handleReset">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
重置
|
||||
</ElButton>
|
||||
<ElButton type="primary" :disabled="props.disabled" @click="handleSearch">
|
||||
<template #icon>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
查询
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 展开后的后续行 -->
|
||||
<ElRow v-if="expanded && remainingFields.length > 0" :gutter="props.gutter">
|
||||
<ElCol v-for="field in remainingFields" :key="field.key" class="table-search-fields__col" :span="fieldSpan">
|
||||
<ElFormItem :label="field.label">
|
||||
<ElInput
|
||||
v-if="field.type === 'input'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<ElSelect
|
||||
v-else-if="field.type === 'select'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
>
|
||||
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||
<template v-if="field.renderOption" #default>
|
||||
<component :is="field.renderOption(opt)" />
|
||||
</template>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="date"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<ElDatePicker
|
||||
v-else-if="field.type === 'dateRange'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
type="daterange"
|
||||
:placeholder="field.placeholder"
|
||||
clearable
|
||||
:disabled="props.disabled"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
<DictSelect
|
||||
v-else-if="field.type === 'dict'"
|
||||
:model-value="props.modelValue[field.key]"
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-form-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-search-fields__col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-search-fields__placeholder-col {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-search-fields__actions {
|
||||
:deep(.el-form-item__content) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input),
|
||||
:deep(.el-select),
|
||||
:deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,20 @@ export const commonStatusOptions = [
|
||||
{ value: 1, label: commonStatusRecord[1] }
|
||||
] satisfies CommonType.Option<Api.SystemManage.CommonStatus, App.I18n.I18nKey>[];
|
||||
|
||||
export const scopeTypeRecord: Record<Api.SystemManage.ScopeType, App.I18n.I18nKey> = {
|
||||
global: 'page.system.common.scopeType.global',
|
||||
object: 'page.system.common.scopeType.object'
|
||||
};
|
||||
|
||||
export const scopeTypeOptions = transformRecordToOption(scopeTypeRecord);
|
||||
|
||||
export const objectTypeRecord: Record<Api.SystemManage.ObjectType, App.I18n.I18nKey> = {
|
||||
product: 'page.system.common.objectType.product',
|
||||
project: 'page.system.common.objectType.project'
|
||||
};
|
||||
|
||||
export const objectTypeOptions = transformRecordToOption(objectTypeRecord);
|
||||
|
||||
export const dictStatusRecord: Record<'0' | '1', App.I18n.I18nKey> = {
|
||||
'0': 'page.system.common.status.enable',
|
||||
'1': 'page.system.common.status.disable'
|
||||
@@ -75,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
|
||||
};
|
||||
|
||||
export const postTypeOptions = transformRecordToOption(postTypeRecord);
|
||||
|
||||
/**
|
||||
* 产品对象域角色编码:产品经理
|
||||
*
|
||||
* 用途:
|
||||
* 产品创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
|
||||
* 返回的角色列表中反查产品经理角色 ID,作为默认经理成员行的 roleId 提交。
|
||||
*
|
||||
* 来源口径:后端约定的产品对象域内置角色稳定 code。code 变更需同步前端常量。
|
||||
*/
|
||||
export const PRODUCT_MANAGER_ROLE_CODE = 'product_manager';
|
||||
|
||||
/**
|
||||
* 项目对象域角色编码:项目经理
|
||||
*
|
||||
* 用途:
|
||||
* 项目创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
|
||||
* 返回的角色列表中反查项目经理角色 ID,作为默认经理成员行的 roleId 提交。
|
||||
*
|
||||
* 来源口径:后端约定的项目对象域内置角色稳定 code。code 变更需同步前端常量。
|
||||
*/
|
||||
export const PROJECT_MANAGER_ROLE_CODE = 'project_manager';
|
||||
|
||||
113
src/constants/dict.ts
Normal file
113
src/constants/dict.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 运行时字典编码常量
|
||||
*
|
||||
* 约定:
|
||||
* 1. 不要在业务页面硬编码 dictType。
|
||||
* 2. 新增字典编码前,先从“后端接口文档 / 后端字段契约 / 系统字典管理页”确认真实 dictType。
|
||||
* 3. 确认后再收敛到本文件,并补上中文注释说明“这个编码对应哪个业务字段”。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 对象方向字典编码
|
||||
*
|
||||
* 对应业务字段:产品、项目及后续其他对象中的 directionCode / direction
|
||||
* 来源口径:
|
||||
* 1. 方向类业务语义已经纠正为“对象通用方向”
|
||||
* 2. 后端字典编码已准备切到更准确的 rdms_object_direction
|
||||
*
|
||||
* 说明:
|
||||
* 前端页面统一使用本常量,不再继续使用带 product 痕迹的旧命名。
|
||||
*/
|
||||
export const RDMS_OBJECT_DIRECTION_DICT_CODE = 'rdms_object_direction';
|
||||
|
||||
/**
|
||||
* 对象方向历史字典编码
|
||||
*
|
||||
* 用途:
|
||||
* 仅用于前后端切换期间兼容旧数据,不允许新页面直接使用。
|
||||
*/
|
||||
export const RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE = 'rdms_product_direction';
|
||||
|
||||
/**
|
||||
* 用户所属公司字典编码
|
||||
*
|
||||
* 对应业务字段:用户相关接口和页面中的 company
|
||||
* 来源口径:当前系统"用户管理"页面按系统字典 system_user_company 做下拉和文案回显
|
||||
*/
|
||||
export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
|
||||
|
||||
/**
|
||||
* 需求来源类型字典编码
|
||||
*
|
||||
* 对应业务字段:需求相关接口和页面中的 sourceType
|
||||
* 来源口径:产品需求文档中定义,标签包括工单流转、手动新增
|
||||
*/
|
||||
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
||||
|
||||
/**
|
||||
* 优先级字典编码
|
||||
*
|
||||
* 对应业务字段:
|
||||
* - 需求(产品需求 / 项目需求)的 priority(旧口径:Integer,数字大=高,0=低 / 3=紧急)
|
||||
* - 任务 / 执行的 priority(新口径:String "0"~"3",数字越小优先级越高,"1"=默认 P1)
|
||||
*
|
||||
* 来源口径:后端统一字典 rdms_req_priority,4 档标签 P0/P1/P2/P3。
|
||||
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
|
||||
*/
|
||||
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
||||
|
||||
/**
|
||||
* 需求分类字典编码
|
||||
*
|
||||
* 对应业务字段:需求相关接口和页面中的 category
|
||||
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
|
||||
*/
|
||||
export const RDMS_REQ_CATEGORY_DICT_CODE = 'rdms_req_category';
|
||||
|
||||
/**
|
||||
* 项目类型字典编码
|
||||
*
|
||||
* 对应业务字段:项目相关接口和页面中的 projectType
|
||||
* 来源口径:后端字典 rdms_project_type
|
||||
*/
|
||||
export const RDMS_PROJECT_TYPE_DICT_CODE = 'rdms_project_type';
|
||||
|
||||
/**
|
||||
* 项目执行类型字典编码
|
||||
*
|
||||
* 对应业务字段:项目任务管理中执行的 executionType
|
||||
* 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 rdms_project_execution_type
|
||||
*/
|
||||
export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_type';
|
||||
|
||||
/**
|
||||
* 状态机对象类型字典编码
|
||||
*
|
||||
* 对应业务字段:状态机管理中的 objectType / 对象类型
|
||||
* 来源口径:用户明确指定对象类型下拉来自运行时字典 object_status_model_object_type
|
||||
*/
|
||||
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
|
||||
|
||||
/**
|
||||
* 任务/个人事项类型字典编码
|
||||
*
|
||||
* 对应业务字段:任务、个人事项中的 type
|
||||
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task_item_type
|
||||
*/
|
||||
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task_item_type';
|
||||
|
||||
/**
|
||||
* 需求允许删除的状态字典编码
|
||||
*
|
||||
* 对应业务字段:需求删除功能中判断 statusCode 是否允许删除
|
||||
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
||||
*/
|
||||
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
||||
|
||||
/**
|
||||
* 工作日志难度字典编码
|
||||
*
|
||||
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
|
||||
*/
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
|
||||
59
src/constants/object-context.ts
Normal file
59
src/constants/object-context.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WEB_SERVICE_PREFIX } from './service';
|
||||
|
||||
export const OBJECT_CONTEXT_QUERY_KEY = 'objectId' as const;
|
||||
|
||||
export const objectContextDomainConfigs: App.ObjectContext.DomainConfig[] = [
|
||||
{
|
||||
domainKey: 'project',
|
||||
mode: 'object-context',
|
||||
objectType: 'project',
|
||||
routePathPrefixes: ['/project'],
|
||||
entryRouteKey: 'project_list',
|
||||
entryRoutePath: '/project/list',
|
||||
fallbackDefaultRouteKey: 'project_project_overview',
|
||||
fallbackDefaultRoutePath: '/project/project/overview',
|
||||
contextApiPath: `${WEB_SERVICE_PREFIX}/project/project/{id}/context`,
|
||||
contextApiObjectIdParamKey: 'id',
|
||||
contextApiObjectIdPlacement: 'path',
|
||||
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
|
||||
},
|
||||
{
|
||||
domainKey: 'product',
|
||||
mode: 'object-context',
|
||||
objectType: 'product',
|
||||
routePathPrefixes: ['/product'],
|
||||
entryRouteKey: 'product_list',
|
||||
entryRoutePath: '/product/list',
|
||||
fallbackDefaultRouteKey: 'product_dashboard',
|
||||
fallbackDefaultRoutePath: '/product/dashboard',
|
||||
contextApiPath: `${WEB_SERVICE_PREFIX}/project/product/{id}/context`,
|
||||
contextApiObjectIdParamKey: 'id',
|
||||
contextApiObjectIdPlacement: 'path',
|
||||
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
|
||||
}
|
||||
];
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
function isPathMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedPrefix = normalizePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
export function getObjectContextDomainConfigByPath(path: string) {
|
||||
return objectContextDomainConfigs.find(config =>
|
||||
config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(path, prefix))
|
||||
);
|
||||
}
|
||||
|
||||
export function isObjectContextEntryPath(path: string, config: App.ObjectContext.DomainConfig) {
|
||||
return normalizePath(path) === normalizePath(config.entryRoutePath);
|
||||
}
|
||||
455
src/constants/product-demo.ts
Normal file
455
src/constants/product-demo.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
export interface DemoProductRequirement {
|
||||
id: string;
|
||||
title: string;
|
||||
status: '待评审' | '设计中' | '开发中' | '验证中' | '已完成';
|
||||
priority: 'P0' | 'P1' | 'P2';
|
||||
owner: string;
|
||||
module: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DemoProductRoadmapItem {
|
||||
id: string;
|
||||
title: string;
|
||||
window: string;
|
||||
status: '已排期' | '推进中' | '风险关注';
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type DemoProductManageStatus = '启用产品' | '归档产品' | '暂停产品' | '废弃产品';
|
||||
|
||||
export interface DemoProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
owner: string;
|
||||
department: string;
|
||||
status: '规划中' | '研发中' | '稳定运营';
|
||||
manageStatus: DemoProductManageStatus;
|
||||
stage: '探索' | '增长' | '平台化';
|
||||
version: string;
|
||||
releaseTarget: string;
|
||||
updatedAt: string;
|
||||
health: '健康' | '关注' | '加速';
|
||||
summary: string;
|
||||
tags: string[];
|
||||
teamCount: number;
|
||||
requirementCount: number;
|
||||
bugCount: number;
|
||||
focus: string[];
|
||||
requirements: DemoProductRequirement[];
|
||||
roadmap: DemoProductRoadmapItem[];
|
||||
}
|
||||
|
||||
export const demoProducts: DemoProduct[] = [
|
||||
{
|
||||
id: 'product-alpha',
|
||||
name: '产品中台 Alpha',
|
||||
code: 'ALPHA',
|
||||
owner: '林语辰',
|
||||
department: '平台产品部',
|
||||
status: '研发中',
|
||||
manageStatus: '启用产品',
|
||||
stage: '平台化',
|
||||
version: 'v2.8.0',
|
||||
releaseTarget: '2026-05-10',
|
||||
updatedAt: '2026-04-16',
|
||||
health: '健康',
|
||||
summary: '面向多业务线复用的产品主数据与流程配置中台,当前重点在规则编排和版本发布节奏收口。',
|
||||
tags: ['平台能力', '规则编排', '统一发布'],
|
||||
teamCount: 14,
|
||||
requirementCount: 26,
|
||||
bugCount: 5,
|
||||
focus: ['统一配置台账', '版本灰度策略', '对象权限接入'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-101',
|
||||
title: '支持产品对象上下文的头部导航切换',
|
||||
status: '开发中',
|
||||
priority: 'P0',
|
||||
owner: '赵明远',
|
||||
module: '工作台',
|
||||
updatedAt: '2026-04-15'
|
||||
},
|
||||
{
|
||||
id: 'REQ-108',
|
||||
title: '接入对象成员角色模板的快捷查看',
|
||||
status: '设计中',
|
||||
priority: 'P1',
|
||||
owner: '姜知夏',
|
||||
module: '权限',
|
||||
updatedAt: '2026-04-13'
|
||||
},
|
||||
{
|
||||
id: 'REQ-112',
|
||||
title: '支持发布包差异对比摘要',
|
||||
status: '待评审',
|
||||
priority: 'P1',
|
||||
owner: '周承安',
|
||||
module: '发布',
|
||||
updatedAt: '2026-04-11'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-1',
|
||||
title: '对象上下文导航试点',
|
||||
window: '2026 Q2',
|
||||
status: '推进中',
|
||||
summary: '先在产品域打通对象入口、头部导航、按钮权限隔离。'
|
||||
},
|
||||
{
|
||||
id: 'RM-2',
|
||||
title: '规则编排配置台账',
|
||||
window: '2026 Q2',
|
||||
status: '已排期',
|
||||
summary: '把历史分散配置统一归档到产品规则台账。'
|
||||
},
|
||||
{
|
||||
id: 'RM-3',
|
||||
title: '发布治理看板',
|
||||
window: '2026 Q3',
|
||||
status: '风险关注',
|
||||
summary: '依赖后端事件流与测试数据沉淀,排期受联调进度影响。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-orbit',
|
||||
name: 'Orbit 客户协同端',
|
||||
code: 'ORBIT',
|
||||
owner: '程清和',
|
||||
department: '客户体验部',
|
||||
status: '稳定运营',
|
||||
manageStatus: '归档产品',
|
||||
stage: '增长',
|
||||
version: 'v1.9.3',
|
||||
releaseTarget: '2026-04-28',
|
||||
updatedAt: '2026-04-14',
|
||||
health: '关注',
|
||||
summary: '围绕客户协同与交付反馈的门户产品,近期重点是降低工单回流和优化首屏转化链路。',
|
||||
tags: ['客户协同', '交付门户', '反馈闭环'],
|
||||
teamCount: 10,
|
||||
requirementCount: 18,
|
||||
bugCount: 9,
|
||||
focus: ['首屏引导改版', '交付看板合并', '通知触达回收'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-203',
|
||||
title: '重构客户交付看板首页信息密度',
|
||||
status: '验证中',
|
||||
priority: 'P0',
|
||||
owner: '顾思远',
|
||||
module: '门户',
|
||||
updatedAt: '2026-04-15'
|
||||
},
|
||||
{
|
||||
id: 'REQ-217',
|
||||
title: '补充客户联系人生命周期标签',
|
||||
status: '开发中',
|
||||
priority: 'P1',
|
||||
owner: '何嘉宁',
|
||||
module: '客户画像',
|
||||
updatedAt: '2026-04-12'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-4',
|
||||
title: '客户首页分群策略升级',
|
||||
window: '2026 Q2',
|
||||
status: '推进中',
|
||||
summary: '把静态首页切分为按客户阶段动态呈现的版本。'
|
||||
},
|
||||
{
|
||||
id: 'RM-5',
|
||||
title: '交付反馈闭环自动催办',
|
||||
window: '2026 Q3',
|
||||
status: '已排期',
|
||||
summary: '通过规则任务减少人工跟进成本。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-lighthouse',
|
||||
name: 'Lighthouse 经营驾驶舱',
|
||||
code: 'LIGHT',
|
||||
owner: '宋知序',
|
||||
department: '商业产品部',
|
||||
status: '稳定运营',
|
||||
manageStatus: '启用产品',
|
||||
stage: '增长',
|
||||
version: 'v3.2.1',
|
||||
releaseTarget: '2026-05-22',
|
||||
updatedAt: '2026-04-15',
|
||||
health: '健康',
|
||||
summary: '承接经营看板、指标订阅和异常播报的统一产品驾驶舱,当前聚焦跨部门指标口径收敛与高频场景提效。',
|
||||
tags: ['经营分析', '指标订阅', '统一驾驶舱'],
|
||||
teamCount: 12,
|
||||
requirementCount: 21,
|
||||
bugCount: 3,
|
||||
focus: ['指标口径治理', '异常订阅编排', '高层驾驶舱视图'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-221',
|
||||
title: '支持核心经营指标的口径版本管理',
|
||||
status: '开发中',
|
||||
priority: 'P0',
|
||||
owner: '孟之遥',
|
||||
module: '指标中心',
|
||||
updatedAt: '2026-04-14'
|
||||
},
|
||||
{
|
||||
id: 'REQ-228',
|
||||
title: '补齐驾驶舱异常波动播报模板',
|
||||
status: '待评审',
|
||||
priority: 'P1',
|
||||
owner: '韩屿川',
|
||||
module: '播报',
|
||||
updatedAt: '2026-04-11'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-8',
|
||||
title: '经营指标主题化看板升级',
|
||||
window: '2026 Q2',
|
||||
status: '推进中',
|
||||
summary: '将现有指标页按经营主题重组,减少跨页面跳转成本。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-pulse',
|
||||
name: 'Pulse 消息协同台',
|
||||
code: 'PULSE',
|
||||
owner: '许闻洲',
|
||||
department: '协同平台部',
|
||||
status: '规划中',
|
||||
manageStatus: '启用产品',
|
||||
stage: '探索',
|
||||
version: 'v0.6.4',
|
||||
releaseTarget: '2026-05-30',
|
||||
updatedAt: '2026-04-13',
|
||||
health: '关注',
|
||||
summary: '统一承接站内消息、流程通知与消息编排的试点产品,当前重点是通知模板复用与多渠道触达一致性。',
|
||||
tags: ['消息编排', '流程通知', '多渠道触达'],
|
||||
teamCount: 7,
|
||||
requirementCount: 13,
|
||||
bugCount: 2,
|
||||
focus: ['模板复用', '渠道一致性', '消息审计留痕'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-331',
|
||||
title: '梳理流程类通知的统一模板规范',
|
||||
status: '设计中',
|
||||
priority: 'P1',
|
||||
owner: '丁和畅',
|
||||
module: '模板中心',
|
||||
updatedAt: '2026-04-12'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-9',
|
||||
title: '流程消息中心试点',
|
||||
window: '2026 Q2',
|
||||
status: '已排期',
|
||||
summary: '先打通审批、告警两条主链路,验证模板与渠道编排能力。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-nova',
|
||||
name: 'Nova 数据服务台',
|
||||
code: 'NOVA',
|
||||
owner: '陆闻笙',
|
||||
department: '数据中台部',
|
||||
status: '研发中',
|
||||
manageStatus: '暂停产品',
|
||||
stage: '探索',
|
||||
version: 'v0.9.0',
|
||||
releaseTarget: '2026-06-18',
|
||||
updatedAt: '2026-04-12',
|
||||
health: '加速',
|
||||
summary: '承接跨系统数据接入、数据模型装配与查询服务的试点产品,当前仍在能力边界探索阶段。',
|
||||
tags: ['数据服务', '模型装配', '试点产品'],
|
||||
teamCount: 8,
|
||||
requirementCount: 11,
|
||||
bugCount: 4,
|
||||
focus: ['接入链路模板化', '查询 SLA 监控', '多租户样例沉淀'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-301',
|
||||
title: '沉淀数据接入模板库',
|
||||
status: '开发中',
|
||||
priority: 'P0',
|
||||
owner: '沈南舟',
|
||||
module: '接入',
|
||||
updatedAt: '2026-04-16'
|
||||
},
|
||||
{
|
||||
id: 'REQ-306',
|
||||
title: '接入失败告警卡片化展示',
|
||||
status: '设计中',
|
||||
priority: 'P2',
|
||||
owner: '夏安宁',
|
||||
module: '监控',
|
||||
updatedAt: '2026-04-10'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-6',
|
||||
title: '试点租户接入扩容',
|
||||
window: '2026 Q2',
|
||||
status: '推进中',
|
||||
summary: '把当前 2 个试点租户扩到 6 个,验证模型复用率。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-atlas',
|
||||
name: 'Atlas 组织配置台',
|
||||
code: 'ATLAS',
|
||||
owner: '冯见山',
|
||||
department: '企业应用部',
|
||||
status: '稳定运营',
|
||||
manageStatus: '归档产品',
|
||||
stage: '平台化',
|
||||
version: 'v2.6.8',
|
||||
releaseTarget: '2026-02-28',
|
||||
updatedAt: '2026-04-09',
|
||||
health: '健康',
|
||||
summary: '曾用于统一组织架构、岗位映射和通讯录同步的配置平台,现已完成能力迁移,仅作为历史归档保留。',
|
||||
tags: ['组织配置', '历史归档', '同步映射'],
|
||||
teamCount: 6,
|
||||
requirementCount: 8,
|
||||
bugCount: 0,
|
||||
focus: ['历史配置追溯', '迁移审计', '只读查询'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-510',
|
||||
title: '补充组织配置迁移后的审计说明',
|
||||
status: '已完成',
|
||||
priority: 'P2',
|
||||
owner: '罗听雪',
|
||||
module: '审计',
|
||||
updatedAt: '2026-04-06'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-10',
|
||||
title: '归档访问范围收口',
|
||||
window: '2026 Q2',
|
||||
status: '已排期',
|
||||
summary: '控制仅审计角色可访问历史配置详情,普通角色只看摘要。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-sprint',
|
||||
name: 'Sprint 交付排期台',
|
||||
code: 'SPRINT',
|
||||
owner: '魏书言',
|
||||
department: '交付效能部',
|
||||
status: '研发中',
|
||||
manageStatus: '暂停产品',
|
||||
stage: '增长',
|
||||
version: 'v1.3.0',
|
||||
releaseTarget: '2026-06-08',
|
||||
updatedAt: '2026-04-08',
|
||||
health: '关注',
|
||||
summary: '面向交付里程碑排期、风险跟踪和协作节奏对齐的产品,当前因上游流程调整进入阶段性暂停。',
|
||||
tags: ['交付排期', '风险跟踪', '协作节奏'],
|
||||
teamCount: 9,
|
||||
requirementCount: 15,
|
||||
bugCount: 6,
|
||||
focus: ['排期模板统一', '跨团队风险同步', '里程碑预警'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-612',
|
||||
title: '梳理暂停期间保留的风险同步能力范围',
|
||||
status: '待评审',
|
||||
priority: 'P1',
|
||||
owner: '徐青禾',
|
||||
module: '风险中心',
|
||||
updatedAt: '2026-04-07'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-11',
|
||||
title: '暂停期能力边界梳理',
|
||||
window: '2026 Q2',
|
||||
status: '风险关注',
|
||||
summary: '待交付流程新方案确定后,再决定是否恢复后续迭代。'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-legacy',
|
||||
name: 'Legacy 营销活动台',
|
||||
code: 'LEGACY',
|
||||
owner: '陈念初',
|
||||
department: '增长运营部',
|
||||
status: '稳定运营',
|
||||
manageStatus: '废弃产品',
|
||||
stage: '增长',
|
||||
version: 'v3.4.1',
|
||||
releaseTarget: '2026-03-18',
|
||||
updatedAt: '2026-03-25',
|
||||
health: '关注',
|
||||
summary: '面向历史营销活动配置与投放归档的旧产品,目前仅保留数据查询和审计访问能力,不再纳入持续建设计划。',
|
||||
tags: ['历史归档', '活动投放', '审计留痕'],
|
||||
teamCount: 5,
|
||||
requirementCount: 6,
|
||||
bugCount: 1,
|
||||
focus: ['历史活动追溯', '旧投放数据迁移', '权限范围收敛'],
|
||||
requirements: [
|
||||
{
|
||||
id: 'REQ-401',
|
||||
title: '补充历史活动包的只读访问说明',
|
||||
status: '已完成',
|
||||
priority: 'P2',
|
||||
owner: '白昭宁',
|
||||
module: '审计',
|
||||
updatedAt: '2026-03-20'
|
||||
}
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
id: 'RM-7',
|
||||
title: '历史活动数据归档收尾',
|
||||
window: '2026 Q1',
|
||||
status: '已排期',
|
||||
summary: '只保留审计查询链路,后续不再承接新的活动能力建设。'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function getDemoProductById(productId: string) {
|
||||
return demoProducts.find(item => item.id === productId) || null;
|
||||
}
|
||||
|
||||
export function getProductStatusType(status: DemoProduct['status']) {
|
||||
const statusTypeMap: Record<DemoProduct['status'], 'success' | 'warning' | 'info'> = {
|
||||
规划中: 'info',
|
||||
研发中: 'warning',
|
||||
稳定运营: 'success'
|
||||
};
|
||||
|
||||
return statusTypeMap[status];
|
||||
}
|
||||
|
||||
export function getProductHealthType(health: DemoProduct['health']) {
|
||||
const healthTypeMap: Record<DemoProduct['health'], 'success' | 'warning' | 'danger'> = {
|
||||
健康: 'success',
|
||||
关注: 'warning',
|
||||
加速: 'danger'
|
||||
};
|
||||
|
||||
return healthTypeMap[health];
|
||||
}
|
||||
77
src/constants/status-tag.ts
Normal file
77
src/constants/status-tag.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 业务对象状态颜色(ElTag type)集中配置
|
||||
*
|
||||
* 各业务域的 statusCode → ElTag type 在此统一维护,避免散落在各业务模块。
|
||||
* 未来若后端状态字典返回颜色字段,可在调用方优先取后端值,缺失时回退此映射。
|
||||
*/
|
||||
|
||||
export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger';
|
||||
|
||||
export type StatusDomain =
|
||||
| 'projectExecution'
|
||||
| 'projectTask'
|
||||
| 'executionAssignee'
|
||||
| 'taskAssigneeMember'
|
||||
| 'project'
|
||||
| 'product'
|
||||
| 'requirement'
|
||||
| 'workOrder'
|
||||
| 'personalItem';
|
||||
|
||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||
// 项目-执行
|
||||
projectExecution: {
|
||||
pending: 'info',
|
||||
active: 'primary',
|
||||
paused: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'danger'
|
||||
},
|
||||
// 项目-任务
|
||||
projectTask: {
|
||||
pending: 'info',
|
||||
active: 'primary',
|
||||
paused: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'danger'
|
||||
},
|
||||
// 执行协办人变更事件
|
||||
executionAssignee: {
|
||||
join: 'success',
|
||||
inactive: 'danger',
|
||||
owner_transfer_in: 'warning',
|
||||
owner_transfer_out: 'warning'
|
||||
},
|
||||
// 任务协办人变更事件
|
||||
taskAssigneeMember: {
|
||||
join: 'success',
|
||||
inactive: 'danger'
|
||||
},
|
||||
// 项目(待补全)
|
||||
project: {},
|
||||
// 产品(待补全)
|
||||
product: {},
|
||||
// 需求(待补全)
|
||||
requirement: {},
|
||||
// 工单(待补全)
|
||||
workOrder: {},
|
||||
// 个人事项
|
||||
personalItem: {
|
||||
pending: 'info',
|
||||
active: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'danger'
|
||||
}
|
||||
};
|
||||
|
||||
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
||||
if (!statusCode) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return statusTagTypeRegistry[domain][statusCode] || 'info';
|
||||
}
|
||||
|
||||
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
|
||||
return getStatusTagType('personalItem', statusCode);
|
||||
}
|
||||
46
src/directives/auth-shared.ts
Normal file
46
src/directives/auth-shared.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type AuthSource = 'global' | 'object' | 'both';
|
||||
|
||||
export interface AuthDirectiveBindingValue {
|
||||
code: string | string[];
|
||||
source?: AuthSource;
|
||||
}
|
||||
|
||||
export interface AuthDirectiveCodeSource {
|
||||
globalButtonCodes: string[];
|
||||
objectButtonCodes: string[];
|
||||
}
|
||||
|
||||
function normalizeCodes(codes: string | string[]) {
|
||||
return Array.isArray(codes) ? codes : [codes];
|
||||
}
|
||||
|
||||
function includesAny(sourceCodes: string[], targetCodes: string[]) {
|
||||
return targetCodes.some(code => sourceCodes.includes(code));
|
||||
}
|
||||
|
||||
export function resolveAuthVisible(
|
||||
bindingValue: string | AuthDirectiveBindingValue,
|
||||
codeSource: AuthDirectiveCodeSource
|
||||
) {
|
||||
const resolvedBinding =
|
||||
typeof bindingValue === 'string'
|
||||
? {
|
||||
code: bindingValue,
|
||||
source: 'global' as const
|
||||
}
|
||||
: bindingValue;
|
||||
|
||||
const targetCodes = normalizeCodes(resolvedBinding.code);
|
||||
const hasGlobal = includesAny(codeSource.globalButtonCodes, targetCodes);
|
||||
const hasObject = includesAny(codeSource.objectButtonCodes, targetCodes);
|
||||
|
||||
if (resolvedBinding.source === 'object') {
|
||||
return hasObject;
|
||||
}
|
||||
|
||||
if (resolvedBinding.source === 'both') {
|
||||
return hasGlobal || hasObject;
|
||||
}
|
||||
|
||||
return hasGlobal;
|
||||
}
|
||||
44
src/directives/auth.ts
Normal file
44
src/directives/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { watchEffect } from 'vue';
|
||||
import type { Directive } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { type AuthDirectiveBindingValue, resolveAuthVisible } from './auth-shared';
|
||||
|
||||
type AuthDirectiveElement = HTMLElement & {
|
||||
authStopHandle?: (() => void) | null;
|
||||
};
|
||||
|
||||
function toggleElementVisible(el: HTMLElement, visible: boolean) {
|
||||
el.style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
function getVisible(bindingValue: string | AuthDirectiveBindingValue) {
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
return resolveAuthVisible(bindingValue, {
|
||||
globalButtonCodes: authStore.userInfo.buttons,
|
||||
objectButtonCodes: objectContextStore.buttonCodes
|
||||
});
|
||||
}
|
||||
|
||||
function bindAuthEffect(el: AuthDirectiveElement, bindingValue: string | AuthDirectiveBindingValue) {
|
||||
el.authStopHandle?.();
|
||||
|
||||
el.authStopHandle = watchEffect(() => {
|
||||
toggleElementVisible(el, getVisible(bindingValue));
|
||||
});
|
||||
}
|
||||
|
||||
export const authDirective: Directive<AuthDirectiveElement, string | AuthDirectiveBindingValue> = {
|
||||
mounted(el, binding) {
|
||||
bindAuthEffect(el, binding.value);
|
||||
},
|
||||
updated(el, binding) {
|
||||
bindAuthEffect(el, binding.value);
|
||||
},
|
||||
unmounted(el) {
|
||||
el.authStopHandle?.();
|
||||
el.authStopHandle = null;
|
||||
}
|
||||
};
|
||||
6
src/directives/index.ts
Normal file
6
src/directives/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { App } from 'vue';
|
||||
import { authDirective } from './auth';
|
||||
|
||||
export function setupDirectives(app: App) {
|
||||
app.directive('auth', authDirective);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export enum SetupStoreId {
|
||||
App = 'app-store',
|
||||
Theme = 'theme-store',
|
||||
Auth = 'auth-store',
|
||||
Dict = 'dict-store',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store'
|
||||
Tab = 'tab-store',
|
||||
ObjectContext = 'object-context-store',
|
||||
Workbench = 'workbench-store'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
|
||||
export function useAuth() {
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
function hasAuth(codes: string | string[]) {
|
||||
if (!authStore.isLogin) {
|
||||
@@ -15,7 +17,14 @@ export function useAuth() {
|
||||
return codes.some(code => authStore.userInfo.buttons.includes(code));
|
||||
}
|
||||
|
||||
function hasObjectAuth(codes: string | string[]) {
|
||||
const targetCodes = typeof codes === 'string' ? [codes] : codes;
|
||||
|
||||
return targetCodes.some(code => objectContextStore.buttonCodes.includes(code));
|
||||
}
|
||||
|
||||
return {
|
||||
hasAuth
|
||||
hasAuth,
|
||||
hasObjectAuth
|
||||
};
|
||||
}
|
||||
|
||||
86
src/hooks/business/dict.ts
Normal file
86
src/hooks/business/dict.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
|
||||
type DictCode = string | Ref<string> | ComputedRef<string>;
|
||||
type DictValue = string | number | null | undefined;
|
||||
type DictValueList = Array<DictValue> | null | undefined;
|
||||
type DictFilterOptions = {
|
||||
onlyEnabled?: boolean;
|
||||
};
|
||||
type DictLabelOptions = string | (DictFilterOptions & { fallback?: string });
|
||||
type DictLabelsOptions = string | (DictFilterOptions & { fallback?: string; separator?: string });
|
||||
|
||||
function normalizeLabelOptions(options?: DictLabelOptions) {
|
||||
if (typeof options === 'string') {
|
||||
return {
|
||||
fallback: options,
|
||||
onlyEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fallback: options?.fallback ?? '--',
|
||||
onlyEnabled: options?.onlyEnabled ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLabelsOptions(options?: DictLabelsOptions) {
|
||||
if (typeof options === 'string') {
|
||||
return {
|
||||
fallback: options,
|
||||
separator: ' / ',
|
||||
onlyEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fallback: options?.fallback ?? '--',
|
||||
separator: options?.separator ?? ' / ',
|
||||
onlyEnabled: options?.onlyEnabled ?? false
|
||||
};
|
||||
}
|
||||
|
||||
export function useDict(dictCode: DictCode | MaybeRefOrGetter<string>) {
|
||||
const dictStore = useDictStore();
|
||||
|
||||
const currentDictCode = computed(() => toValue(dictCode));
|
||||
|
||||
const dictData = computed(() => dictStore.getDictData(currentDictCode.value));
|
||||
const enabledDictData = computed(() => dictStore.getDictData(currentDictCode.value, true));
|
||||
const dictOptions = computed(() => dictStore.getDictOptions(currentDictCode.value));
|
||||
const dictMap = computed(() => new Map(dictData.value.map(item => [item.value, item])));
|
||||
const enabledDictMap = computed(() => new Map(enabledDictData.value.map(item => [item.value, item])));
|
||||
|
||||
function getItem(value?: DictValue, options: DictFilterOptions = {}) {
|
||||
return dictStore.getDictItem(currentDictCode.value, value, options);
|
||||
}
|
||||
|
||||
function getLabel(value?: DictValue, options?: DictLabelOptions) {
|
||||
const normalizedOptions = normalizeLabelOptions(options);
|
||||
|
||||
return dictStore.getDictLabel(currentDictCode.value, value, normalizedOptions);
|
||||
}
|
||||
|
||||
function getLabels(values?: DictValueList, options?: DictLabelsOptions) {
|
||||
const normalizedOptions = normalizeLabelsOptions(options);
|
||||
|
||||
return dictStore.getDictLabels(currentDictCode.value, values, normalizedOptions);
|
||||
}
|
||||
|
||||
function hasValue(value?: DictValue, options: DictFilterOptions = {}) {
|
||||
return dictStore.hasDictValue(currentDictCode.value, value, options);
|
||||
}
|
||||
|
||||
return {
|
||||
dictData,
|
||||
enabledDictData,
|
||||
dictOptions,
|
||||
dictMap,
|
||||
enabledDictMap,
|
||||
getItem,
|
||||
getLabel,
|
||||
getLabels,
|
||||
hasValue
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { router as globalRouter } from '@/router';
|
||||
import { getGlobalRouter } from '@/router/instance';
|
||||
|
||||
/**
|
||||
* Router push
|
||||
@@ -11,6 +11,7 @@ import { router as globalRouter } from '@/router';
|
||||
* @param inSetup Whether is in vue script setup
|
||||
*/
|
||||
export function useRouterPush(inSetup = true) {
|
||||
const globalRouter = getGlobalRouter();
|
||||
const router = inSetup ? useRouter() : globalRouter;
|
||||
const route = globalRouter.currentRoute;
|
||||
|
||||
|
||||
@@ -16,18 +16,18 @@ defineOptions({ name: 'BaseLayout' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
|
||||
const { childLevelMenus } = setupMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
const layoutMode = computed(() => {
|
||||
const vertical: LayoutMode = 'vertical';
|
||||
const horizontal: LayoutMode = 'horizontal';
|
||||
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
|
||||
return themeStore.layoutMode.includes(vertical) ? vertical : horizontal;
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode, reverseHorizontalMix } = themeStore.layout;
|
||||
const mode = themeStore.layoutMode;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
@@ -48,31 +48,26 @@ const headerProps = computed(() => {
|
||||
'horizontal-mix': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
|
||||
showMenuToggler: false
|
||||
}
|
||||
};
|
||||
|
||||
return headerPropsConfig[mode];
|
||||
});
|
||||
|
||||
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
|
||||
const siderVisible = computed(() => themeStore.layoutMode !== 'horizontal');
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
const isVerticalMix = computed(() => themeStore.layoutMode === 'vertical-mix');
|
||||
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layoutMode === 'horizontal-mix');
|
||||
|
||||
const siderWidth = computed(() => getSiderWidth());
|
||||
|
||||
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
|
||||
|
||||
function getSiderWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
@@ -83,13 +78,8 @@ function getSiderWidth() {
|
||||
}
|
||||
|
||||
function getSiderCollapsedWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
@@ -110,7 +100,7 @@ function getSiderCollapsedWidth() {
|
||||
:full-content="appStore.fullContent"
|
||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||
:header-height="themeStore.header.height"
|
||||
:tab-visible="themeStore.tab.visible"
|
||||
:tab-visible="themeStore.tabVisible"
|
||||
:tab-height="themeStore.tab.height"
|
||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||
:sider-visible="siderVisible"
|
||||
|
||||
@@ -2,30 +2,49 @@ import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
|
||||
|
||||
function isMenuMatchedByPath(path: string, menuPath: string) {
|
||||
if (!menuPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return path === menuPath || path.startsWith(`${menuPath}/`);
|
||||
}
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
const [firstLevelMenuKey = ''] = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
if (firstLevelMenuKey) {
|
||||
setActiveFirstLevelMenuKey(firstLevelMenuKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackFirstLevelMenuKey =
|
||||
allMenus.value.find(menu => isMenuMatchedByPath(route.path, menu.routePath))?.key || '';
|
||||
|
||||
setActiveFirstLevelMenuKey(fallbackFirstLevelMenuKey);
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
@@ -38,6 +57,25 @@ function useMixMenu() {
|
||||
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const currentObjectContextDomain = computed(() => getObjectContextDomainConfigByPath(route.path) || null);
|
||||
|
||||
const headerMenuMode = computed<'global' | 'object-context'>(() =>
|
||||
currentObjectContextDomain.value && objectContextStore.hasContext ? 'object-context' : 'global'
|
||||
);
|
||||
|
||||
const headerMenus = computed<(App.Global.Menu | App.ObjectContext.Menu)[]>(() => {
|
||||
if (headerMenuMode.value === 'object-context') {
|
||||
return objectContextStore.contextScopedMenus;
|
||||
}
|
||||
|
||||
// 对象型业务域处于入口态时,头部只保留业务域锚点,不继续投影全局子菜单。
|
||||
if (currentObjectContextDomain.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return childLevelMenus.value;
|
||||
});
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
@@ -49,7 +87,7 @@ function useMixMenu() {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
[selectedKey, allMenus, () => route.path],
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
@@ -60,6 +98,9 @@ function useMixMenu() {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
headerMenuMode,
|
||||
headerMenus,
|
||||
currentObjectContextDomain,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'NotificationBell' });
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
||||
function buildMockNotifications(): NotificationItem[] {
|
||||
const titles = [
|
||||
'你被指派为执行「迭代 24.06」负责人',
|
||||
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
||||
'需求「多币种支持」评审通过',
|
||||
'工单 #1042 已分派给你',
|
||||
'需求「订单导出」被退回,请补充材料',
|
||||
'@ 你的评论已被回复',
|
||||
'项目「客户中心 2.0」周报已生成',
|
||||
'工单 #1098 客户回复待处理',
|
||||
'执行「迭代 24.05」已结束',
|
||||
'需求「批量审批」分配给你'
|
||||
];
|
||||
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
|
||||
return Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `m${i + 1}`,
|
||||
title: `${titles[i % titles.length]}(#${i + 1})`,
|
||||
timeLabel: times[Math.floor(i / 6) % times.length],
|
||||
unread: i < 14
|
||||
}));
|
||||
}
|
||||
|
||||
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
||||
|
||||
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
|
||||
const readAll = computed(() => notifications.value.filter(n => !n.unread));
|
||||
const unreadCount = computed(() => unreadAll.value.length);
|
||||
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const activeTab = ref<'unread' | 'read'>('unread');
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function matchesKeyword(item: NotificationItem) {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return true;
|
||||
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||
}
|
||||
|
||||
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||
|
||||
const unreadPageSize = ref(PAGE_SIZE);
|
||||
const readPageSize = ref(PAGE_SIZE);
|
||||
|
||||
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||
|
||||
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
||||
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
unreadPageSize.value = PAGE_SIZE;
|
||||
readPageSize.value = PAGE_SIZE;
|
||||
});
|
||||
|
||||
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||
|
||||
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => unreadScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => readScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function markRead(item: NotificationItem) {
|
||||
if (!item.unread) return;
|
||||
item.unread = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-read', item.id);
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
notifications.value.forEach(item => {
|
||||
item.unread = false;
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-all-read');
|
||||
}
|
||||
|
||||
function openItem(item: NotificationItem) {
|
||||
markRead(item);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] open', item.id);
|
||||
}
|
||||
|
||||
function onDrawerClosed() {
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="notification-bell__trigger"
|
||||
type="button"
|
||||
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
|
||||
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
|
||||
<div class="notification-bell__panel">
|
||||
<header class="notification-bell__header">
|
||||
<span class="notification-bell__title">
|
||||
通知
|
||||
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||
</span>
|
||||
<span class="notification-bell__header-actions">
|
||||
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
||||
<SvgIcon icon="mdi:close" />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="notification-bell__search">
|
||||
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" class="notification-bell__tabs">
|
||||
<ElTabPane name="unread">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
未读
|
||||
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
||||
<li
|
||||
v-for="row in visibleUnread"
|
||||
:key="row.id"
|
||||
class="notification-bell__row is-unread"
|
||||
@click="openItem(row)"
|
||||
>
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="read">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
已读
|
||||
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
||||
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-bell__trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__trigger:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.notification-bell__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.notification-bell__badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-bell__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__title-count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-bell__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__close:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__search {
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-bell__tab-count {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notification-bell__row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__row + .notification-bell__row {
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__row:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.notification-bell__row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-dot {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__row-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-bell__row-title {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-bell__row-time {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-bell__empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-bell__footer-hint {
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -12,11 +12,13 @@ const authStore = useAuthStore();
|
||||
const { routerPushByKey, toLogin } = useRouterPush();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
|
||||
|
||||
function loginOrRegister() {
|
||||
toLogin();
|
||||
}
|
||||
|
||||
type DropdownKey = 'user-center' | 'logout';
|
||||
type DropdownKey = 'personal-center_my-profile' | 'logout';
|
||||
|
||||
type DropdownOption = {
|
||||
key: DropdownKey;
|
||||
@@ -27,8 +29,8 @@ type DropdownOption = {
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
label: $t('common.userCenter'),
|
||||
key: 'user-center',
|
||||
label: $t('common.myProfile'),
|
||||
key: 'personal-center_my-profile',
|
||||
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
|
||||
</template>
|
||||
<div class="flex items-center">
|
||||
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
|
||||
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
|
||||
<span class="text-16px font-medium">{{ displayName }}</span>
|
||||
</div>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import NotificationBell from './components/notification-bell.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalHeader' });
|
||||
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
|
||||
@@ -14,9 +14,12 @@ withDefaults(defineProps<Props>(), {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||
<SystemLogo class="text-32px text-primary" />
|
||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||
<RouterLink to="/" class="h-full w-full flex-y-center justify-start gap-8px nowrap-hidden px-12px">
|
||||
<SystemLogo class="shrink-0 text-32px text-primary" />
|
||||
<h2
|
||||
v-show="showTitle"
|
||||
class="min-w-0 flex-1-hidden ellipsis-text text-16px text-primary font-bold transition duration-300 ease-in-out"
|
||||
>
|
||||
{{ $t('system.title') }}
|
||||
</h2>
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
|
||||
defineOptions({ name: 'ObjectContextSwitcher' });
|
||||
|
||||
interface Props {
|
||||
domainConfig: App.ObjectContext.DomainConfig;
|
||||
}
|
||||
|
||||
type ObjectOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
createTime?: string | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
const visible = ref(false);
|
||||
const keyword = ref('');
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const switchingId = ref('');
|
||||
const options = ref<ObjectOption[]>([]);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const OBJECT_SWITCHER_PAGE_SIZE = 100;
|
||||
|
||||
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
|
||||
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
|
||||
const allLabel = computed(() => `全部${domainLabel.value}`);
|
||||
const placeholder = computed(() => `搜索${domainLabel.value}`);
|
||||
const previewOptions = computed(() => options.value.slice(0, 3));
|
||||
const displayOptions = computed(() => {
|
||||
if (keyword.value.trim() || expanded.value) {
|
||||
return options.value;
|
||||
}
|
||||
|
||||
return previewOptions.value;
|
||||
});
|
||||
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
|
||||
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
|
||||
|
||||
function sortByCreateTimeDesc(list: ObjectOption[]) {
|
||||
return list.slice().sort((left, right) => {
|
||||
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
|
||||
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
|
||||
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
|
||||
const result =
|
||||
props.domainConfig.domainKey === 'product'
|
||||
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
|
||||
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return {
|
||||
total: 0,
|
||||
list: []
|
||||
};
|
||||
}
|
||||
|
||||
const list = result.data.list.map(item => {
|
||||
if (props.domainConfig.domainKey === 'product') {
|
||||
const product = item as Api.Product.Product;
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
createTime: product.createTime
|
||||
};
|
||||
}
|
||||
|
||||
const project = item as Api.Project.Project;
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.projectName,
|
||||
code: project.projectCode,
|
||||
createTime: project.createTime
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
total: result.data.total,
|
||||
list
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
|
||||
const keywordValue = keyword.value.trim() || undefined;
|
||||
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
|
||||
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
|
||||
const restPages =
|
||||
pageCount > 1
|
||||
? await Promise.all(
|
||||
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
|
||||
)
|
||||
: [];
|
||||
const list = [firstPage, ...restPages].flatMap(page => page.list);
|
||||
|
||||
loading.value = false;
|
||||
options.value = sortByCreateTimeDesc(list);
|
||||
}
|
||||
|
||||
function handleVisibleChange(value: boolean) {
|
||||
visible.value = value;
|
||||
|
||||
if (value) {
|
||||
expanded.value = false;
|
||||
loadOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(option: ObjectOption) {
|
||||
if (option.id === objectContextStore.objectId) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
switchingId.value = option.id;
|
||||
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
|
||||
switchingId.value = '';
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
const query = {
|
||||
...route.query,
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: option.id
|
||||
};
|
||||
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
|
||||
|
||||
await router.push(targetLocation);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => keyword.value,
|
||||
() => {
|
||||
if (!visible.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expanded.value = Boolean(keyword.value.trim());
|
||||
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
loadOptions();
|
||||
}, 250);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="300"
|
||||
popper-class="object-context-switcher__popper"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
|
||||
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
|
||||
<icon-ep:sort class="object-context-switcher__trigger-icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="object-context-switcher__panel">
|
||||
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
|
||||
<template #suffix>
|
||||
<ElIcon>
|
||||
<Search />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div v-loading="loading" class="object-context-switcher__list">
|
||||
<button
|
||||
v-for="item in displayOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="object-context-switcher__item"
|
||||
:class="{ 'is-active': item.id === objectContextStore.objectId }"
|
||||
:disabled="switchingId === item.id"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<span class="object-context-switcher__item-icon">
|
||||
<icon-ep:box v-if="isProductDomain" />
|
||||
<icon-ep:folder v-else />
|
||||
</span>
|
||||
<span class="object-context-switcher__item-main">
|
||||
<span class="object-context-switcher__item-name">{{ item.name }}</span>
|
||||
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
|
||||
</span>
|
||||
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
|
||||
</button>
|
||||
|
||||
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
|
||||
</div>
|
||||
|
||||
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
|
||||
<span>{{ allLabel }}</span>
|
||||
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
|
||||
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.object-context-switcher__trigger {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 16rem;
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger:hover,
|
||||
.object-context-switcher__trigger.is-open {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.object-context-switcher__search {
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.object-context-switcher__list {
|
||||
min-height: 84px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.object-context-switcher__item {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__item:hover,
|
||||
.object-context-switcher__item.is-active {
|
||||
background: rgb(59 130 246 / 10%);
|
||||
}
|
||||
|
||||
.object-context-switcher__item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__item-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name,
|
||||
.object-context-switcher__item-code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-code {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.object-context-switcher__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.object-context-switcher__all {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% + 24px);
|
||||
height: 38px;
|
||||
gap: 8px;
|
||||
margin: 0 -12px -12px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__all:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__all-meta {
|
||||
flex: 1;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.object-context-switcher__all-arrow {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.object-context-switcher__popper.el-popover) {
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 12px 28px rgb(15 23 42 / 10%),
|
||||
0 2px 8px rgb(15 23 42 / 6%);
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,6 @@ import VerticalMenu from './modules/vertical-menu.vue';
|
||||
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
|
||||
import HorizontalMenu from './modules/horizontal-menu.vue';
|
||||
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
|
||||
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalMenu'
|
||||
@@ -21,13 +20,13 @@ const activeMenu = computed(() => {
|
||||
vertical: VerticalMenu,
|
||||
'vertical-mix': VerticalMixMenu,
|
||||
horizontal: HorizontalMenu,
|
||||
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
|
||||
'horizontal-mix': HorizontalMixMenu
|
||||
};
|
||||
|
||||
return menuMap[themeStore.layout.mode];
|
||||
return menuMap[themeStore.layoutMode];
|
||||
});
|
||||
|
||||
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile);
|
||||
const reRenderVertical = computed(() => themeStore.layoutMode === 'vertical' && appStore.isMobile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,43 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import MenuItem from '../components/menu-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMixMenu'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
const { selectedKeyDummy, handleSelect } = useMenu();
|
||||
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
allMenus,
|
||||
headerMenuMode,
|
||||
headerMenus,
|
||||
currentObjectContextDomain,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey
|
||||
} = useMixMenuContext();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenu = computed(
|
||||
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value) || null
|
||||
);
|
||||
const headerMenuHeight = computed(() => `${themeStore.header.height}px`);
|
||||
const showObjectContextInfo = computed(
|
||||
() => headerMenuMode.value === 'object-context' && objectContextStore.hasContext
|
||||
);
|
||||
const activeHeaderMenuKey = computed(() =>
|
||||
headerMenuMode.value === 'object-context' ? String(route.name || '') : selectedKey.value
|
||||
);
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
if (!menu.children?.length) {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
|
||||
|
||||
if (domainConfig) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: domainConfig.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
|
||||
function handleClickNavItem(menu: App.Global.Menu | App.ObjectContext.Menu) {
|
||||
if (headerMenuMode.value === 'object-context') {
|
||||
const location = objectContextStore.getMenuRouteLocation(menu as App.ObjectContext.Menu);
|
||||
|
||||
if (location) {
|
||||
routerPush(location);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery((menu as App.Global.Menu).routeKey);
|
||||
}
|
||||
|
||||
function handleClickDomainAnchor() {
|
||||
if (currentObjectContextDomain.value) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: currentObjectContextDomain.value.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeFirstLevelMenu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery(activeFirstLevelMenu.value.routeKey);
|
||||
}
|
||||
|
||||
function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
if (menu.key === activeHeaderMenuKey.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return menu.children?.some(child => isMenuActive(child)) || false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<ElMenu
|
||||
ellipsis
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
:default-active="selectedKeyDummy"
|
||||
@select="val => handleSelect(val as RouteKey)"
|
||||
>
|
||||
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||
</ElMenu>
|
||||
<div class="mix-header-nav size-full min-w-0 flex-y-center">
|
||||
<button
|
||||
v-if="activeFirstLevelMenu"
|
||||
type="button"
|
||||
class="domain-anchor h-full flex-y-center gap-8px px-8px text-left"
|
||||
@click="handleClickDomainAnchor"
|
||||
>
|
||||
<component :is="activeFirstLevelMenu.icon" v-if="activeFirstLevelMenu.icon" class="text-icon" />
|
||||
<span class="domain-anchor__label">{{ activeFirstLevelMenu.label }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showObjectContextInfo || headerMenus.length"
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showObjectContextInfo && headerMenus.length"
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="headerMenus.length" class="header-nav-list h-full min-w-0 flex-1">
|
||||
<template v-for="item in headerMenus" :key="item.key">
|
||||
<button
|
||||
v-if="!item.children?.length"
|
||||
type="button"
|
||||
class="header-nav-item"
|
||||
:class="{ 'is-active': isMenuActive(item) }"
|
||||
@click="handleClickNavItem(item)"
|
||||
>
|
||||
<span class="header-nav-item__label">{{ item.label }}</span>
|
||||
</button>
|
||||
<ElDropdown
|
||||
v-else
|
||||
trigger="hover"
|
||||
placement="bottom"
|
||||
popper-class="header-nav-dropdown"
|
||||
:show-timeout="120"
|
||||
:hide-timeout="120"
|
||||
:teleported="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="header-nav-item header-nav-item--dropdown"
|
||||
:class="{ 'is-active': isMenuActive(item) }"
|
||||
>
|
||||
<span class="header-nav-item__label">{{ item.label }}</span>
|
||||
<icon-ep:arrow-down class="header-nav-item__arrow" />
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
class="header-nav-dropdown__item"
|
||||
:class="{ 'is-active-route': isMenuActive(child) }"
|
||||
@click="handleClickNavItem(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<FirstLevelMenu
|
||||
@@ -52,4 +174,138 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.mix-header-nav {
|
||||
height: v-bind(headerMenuHeight);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.domain-anchor {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
font: inherit;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.domain-anchor:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.domain-anchor__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 12rem;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-nav-item {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-nav-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-nav-item__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-nav-item__arrow {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-nav-item.is-active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-nav-item.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown.el-popper) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(255 255 255 / 98%);
|
||||
box-shadow:
|
||||
0 12px 28px rgb(15 23 42 / 10%),
|
||||
0 2px 8px rgb(15 23 42 / 6%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown .el-popper__arrow) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown .el-dropdown-menu) {
|
||||
padding: 8px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown .el-dropdown-menu__item) {
|
||||
height: 40px;
|
||||
margin: 2px 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 40px;
|
||||
color: rgb(15 23 42 / 88%);
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown .el-dropdown-menu__item:hover) {
|
||||
background-color: rgb(99 102 241 / 8%);
|
||||
}
|
||||
|
||||
:global(.header-nav-dropdown .el-dropdown-menu__item.is-active-route) {
|
||||
color: var(--el-color-primary);
|
||||
background-color: rgb(99 102 241 / 10%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,9 @@ import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
@@ -21,9 +23,10 @@ defineOptions({
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
allMenus,
|
||||
@@ -44,6 +47,14 @@ const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value ||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
|
||||
|
||||
if (domainConfig) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: domainConfig.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.children?.length) {
|
||||
setDrawerVisible(true);
|
||||
} else {
|
||||
|
||||
@@ -10,8 +10,8 @@ defineOptions({ name: 'GlobalSider' });
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const isVerticalMix = computed(() => themeStore.layoutMode === 'vertical-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layoutMode === 'horizontal-mix');
|
||||
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
|
||||
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
|
||||
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import DarkMode from './modules/dark-mode.vue';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
import PageFun from './modules/page-fun.vue';
|
||||
import ConfigOperation from './modules/config-operation.vue';
|
||||
@@ -15,7 +14,6 @@ const appStore = useAppStore();
|
||||
<template>
|
||||
<ElDrawer v-model="appStore.themeDrawerVisible" :title="$t('theme.themeDrawerTitle')" :size="360">
|
||||
<DarkMode />
|
||||
<LayoutMode />
|
||||
<ThemeColor />
|
||||
<PageFun />
|
||||
<template #footer>
|
||||
|
||||
@@ -27,7 +27,7 @@ function handleColourWeaknessChange(value: boolean) {
|
||||
themeStore.setColourWeakness(value);
|
||||
}
|
||||
|
||||
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
|
||||
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layoutMode.includes('vertical'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
|
||||
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
@@ -10,7 +10,7 @@ defineOptions({ name: 'PageFun' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const layoutMode = computed(() => themeStore.layout.mode);
|
||||
const layoutMode = computed(() => themeStore.layoutMode);
|
||||
|
||||
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
|
||||
|
||||
@@ -55,25 +55,6 @@ const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wra
|
||||
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
|
||||
<ElSwitch v-model="themeStore.header.breadcrumb.showIcon" />
|
||||
</SettingItem>
|
||||
<SettingItem key="5" :label="$t('theme.tab.visible')">
|
||||
<ElSwitch v-model="themeStore.tab.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
|
||||
<ElSwitch v-model="themeStore.tab.cache" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
|
||||
<ElInputNumber v-model="themeStore.tab.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
|
||||
<ElSelect v-model="themeStore.tab.mode" size="small" class="w-120px">
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(themeTabModeOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
|
||||
<ElInputNumber v-model="themeStore.sider.width" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
|
||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
||||
trigger: 'Trigger',
|
||||
update: 'Update',
|
||||
updateSuccess: 'Update Success',
|
||||
userCenter: 'User Center',
|
||||
myProfile: 'My Profile',
|
||||
yesOrNo: {
|
||||
yes: 'Yes',
|
||||
no: 'No'
|
||||
@@ -158,7 +158,25 @@ const local: App.I18n.Schema = {
|
||||
404: 'Page Not Found',
|
||||
500: 'Server Error',
|
||||
'iframe-page': 'Iframe',
|
||||
'user-center': 'User Center',
|
||||
workbench: 'Workbench',
|
||||
ticket: 'Ticket',
|
||||
'ticket_my-submitted': 'My Submitted',
|
||||
'ticket_my-pending': 'My Pending',
|
||||
metrics: 'Metrics',
|
||||
'metrics_project-progress': 'Project Progress',
|
||||
'metrics_member-efficiency': 'Member Efficiency',
|
||||
metrics_worktime: 'Worktime',
|
||||
'personal-center': 'Personal Center',
|
||||
'personal-center_my-profile': 'My Profile',
|
||||
'personal-center_my-item': 'My Items',
|
||||
'personal-center_my-weekly': 'My Weekly Report',
|
||||
'personal-center_my-monthly': 'My Monthly Report',
|
||||
'personal-center_my-performance': 'My Performance',
|
||||
'personal-center_my-application': 'My Application',
|
||||
'personal-center_pending-approval': 'Pending Approval',
|
||||
infra: 'Infra',
|
||||
'infra_state-machine': 'State Machine',
|
||||
'infra_rd-code': 'R&D Code',
|
||||
function: 'System Function',
|
||||
function_tab: 'Tab',
|
||||
'function_multi-tab': 'Multi Tab',
|
||||
@@ -169,13 +187,26 @@ const local: App.I18n.Schema = {
|
||||
function_request: 'Request',
|
||||
'function_toggle-auth': 'Toggle Auth',
|
||||
'function_super-page': 'Super Admin Visible',
|
||||
system: 'System Management',
|
||||
product: 'Product',
|
||||
product_list: 'Product List',
|
||||
product_dashboard: 'Dashboard',
|
||||
product_requirement: 'Requirement',
|
||||
product_setting: 'Settings',
|
||||
project: 'Project',
|
||||
project_list: 'Project List',
|
||||
project_project: 'Project',
|
||||
project_project_overview: 'Overview',
|
||||
project_project_requirement: 'Requirement',
|
||||
project_project_execution: 'Task Management',
|
||||
project_project_setting: 'Settings',
|
||||
system: 'System',
|
||||
system_user: 'User Management',
|
||||
'system_user-detail': 'User Detail',
|
||||
system_role: 'Role Management',
|
||||
system_menu: 'Menu Management',
|
||||
system_post: 'Post Management',
|
||||
system_dict: 'Dictionary Management',
|
||||
'system_user-management-relation': 'User Management Relation',
|
||||
exception: 'Exception',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
@@ -186,9 +217,6 @@ const local: App.I18n.Schema = {
|
||||
plugin_charts_echarts: 'ECharts',
|
||||
plugin_charts_antv: 'AntV',
|
||||
plugin_charts_vchart: 'VChart',
|
||||
plugin_editor: 'Editor',
|
||||
plugin_editor_quill: 'Quill',
|
||||
plugin_editor_markdown: 'Markdown',
|
||||
plugin_icon: 'Icon',
|
||||
plugin_map: 'Map',
|
||||
plugin_print: 'Print',
|
||||
@@ -343,6 +371,14 @@ const local: App.I18n.Schema = {
|
||||
status: {
|
||||
enable: 'Enable',
|
||||
disable: 'Disable'
|
||||
},
|
||||
scopeType: {
|
||||
global: 'Global',
|
||||
object: 'Object Scope'
|
||||
},
|
||||
objectType: {
|
||||
product: 'Product',
|
||||
project: 'Project'
|
||||
}
|
||||
},
|
||||
role: {
|
||||
@@ -366,6 +402,16 @@ const local: App.I18n.Schema = {
|
||||
selectedCount: 'Selected Resources',
|
||||
disabledTip: 'Disabled roles cannot be assigned menu permissions',
|
||||
emptyRole: 'Select a role first',
|
||||
currentRoleCount: 'Role Count',
|
||||
globalRoleTitle: 'Global Roles',
|
||||
objectRoleTitle: 'Object-Scope Role Templates',
|
||||
globalRoleSummary: 'Manage global login-state roles and their resource authorization relations.',
|
||||
objectRoleSummary:
|
||||
'Manage object-scope role templates and their authorized resources without participating in the global login-state navigation.',
|
||||
objectRoleSummaryProduct:
|
||||
'Manage product-scope role templates and their authorized resources without participating in the global login-state navigation.',
|
||||
objectRoleSummaryProject:
|
||||
'Manage project-scope role templates and their authorized resources without participating in the global login-state navigation.',
|
||||
lastAuthSave: 'Last auth save',
|
||||
unsavedTip: 'Remember to save after changing permissions',
|
||||
form: {
|
||||
@@ -464,6 +510,7 @@ const local: App.I18n.Schema = {
|
||||
orgType: {
|
||||
company: 'Company',
|
||||
dept: 'Department',
|
||||
function: 'Functional Department',
|
||||
direction: 'Direction',
|
||||
team: 'Team'
|
||||
},
|
||||
@@ -491,6 +538,7 @@ const local: App.I18n.Schema = {
|
||||
routeKind: 'Route Kind',
|
||||
routePropsJson: 'Route Props JSON',
|
||||
pageResource: 'Page Resource',
|
||||
boundRoute: 'Bound Route',
|
||||
component: 'Component Path',
|
||||
componentName: 'Component Name',
|
||||
iframeUrl: 'Iframe URL',
|
||||
@@ -520,6 +568,40 @@ const local: App.I18n.Schema = {
|
||||
alwaysShow: 'Always Show',
|
||||
createTime: 'Create Time',
|
||||
topLevel: 'Top Level Menu',
|
||||
scopeType: 'Scope',
|
||||
objectType: 'Object Type',
|
||||
resourceCode: 'Resource Code',
|
||||
contextEyebrow: 'Menu Configuration Context',
|
||||
contextTitle: 'Unified Scope Resource Configuration',
|
||||
contextDescription:
|
||||
'Use one menu page to manage both global route resources and object-scope permission resources, instead of duplicating product and project pages.',
|
||||
currentContext: 'Current Context',
|
||||
currentResourceCount: 'Resource Count',
|
||||
editorMode: 'Editor Mode',
|
||||
editorModeGlobal: 'Route Resource Editor',
|
||||
editorModeObject: 'Object Navigation Editor',
|
||||
globalResourceTitle: 'Global Menu Resources',
|
||||
objectResourceTitle: 'Object-Scope Resources',
|
||||
globalResourceSummary: 'Configure login-state menus, route mappings, and global button permission resources.',
|
||||
objectResourceSummary:
|
||||
'Configure object-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-object permission points.',
|
||||
objectResourceSummaryProduct:
|
||||
'Configure product-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-product permission points.',
|
||||
objectResourceSummaryProject:
|
||||
'Configure project-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-project permission points.',
|
||||
scopeHintGlobal:
|
||||
'Global mode keeps the current route-oriented editor and continues to serve login-state menus and global button permissions.',
|
||||
scopeHintObject:
|
||||
'Object mode manages navigation items and action buttons. Navigation items bind real page resources, and action buttons only maintain permission codes.',
|
||||
objectTypePlaceholder: 'Please select an object type',
|
||||
contextReady: 'Context Selected',
|
||||
contextPending: 'Waiting For Object Type',
|
||||
objectTypeRequiredTitle: 'Select an object type first',
|
||||
objectTypeRequiredDescription:
|
||||
'Object-scope resources must first define the configuration range, such as product or project. Then load the resource tree and editor.',
|
||||
objectModeTipTitle: 'Object scope currently manages navigation items and action buttons',
|
||||
objectModeTipDescription:
|
||||
'In the first phase, object-scope menus only expose navigation items and action buttons. Navigation items bind real page routes for the object header navigation, and action buttons only maintain permission codes. Directory creation is intentionally hidden for now.',
|
||||
sections: {
|
||||
basic: 'Basic Information',
|
||||
route: 'Route Information',
|
||||
@@ -531,6 +613,7 @@ const local: App.I18n.Schema = {
|
||||
parentId: 'Please select parent menu',
|
||||
menuName: 'Please enter menu name',
|
||||
permission: 'Please enter permission',
|
||||
resourceCode: 'Please enter the resource code',
|
||||
routeName: 'Please enter route name',
|
||||
routePath: 'Please enter route path',
|
||||
path: 'Please enter route path',
|
||||
@@ -538,6 +621,7 @@ const local: App.I18n.Schema = {
|
||||
componentName: 'Please enter component name',
|
||||
routeKind: 'Please select route kind',
|
||||
pageResource: 'Please select page resource',
|
||||
boundRoute: 'Please select a bound route',
|
||||
pageResourceParentMismatch: 'The selected page resource does not match the current parent menu path',
|
||||
routePropsJson: 'Please enter a valid JSON string',
|
||||
routePropsJsonHint: 'For example {"url":"https://example.com"}',
|
||||
@@ -585,6 +669,8 @@ const local: App.I18n.Schema = {
|
||||
'Fill in the last segment of the access path. For Role Management, the full path is /system/role, so this field is usually role.',
|
||||
pageResource:
|
||||
'Page routes should select a page resource from the frontend whitelist. For example, Role Management maps to /system/role and view.system_role.',
|
||||
boundRoute:
|
||||
'Object-scope navigation items should bind real object page routes. For example, the product scope can bind /product/dashboard or /product/requirement.',
|
||||
component:
|
||||
'The component field should use the frontend page-resource whitelist key, not a src file path. For Role Management, use or select view.system_role.'
|
||||
},
|
||||
@@ -594,7 +680,9 @@ const local: App.I18n.Schema = {
|
||||
type: {
|
||||
directory: 'Directory',
|
||||
menu: 'Menu',
|
||||
button: 'Button'
|
||||
button: 'Button',
|
||||
navigation: 'Navigation Item',
|
||||
actionButton: 'Action Button'
|
||||
},
|
||||
iconType: {
|
||||
iconify: 'Iconify Icon',
|
||||
|
||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
||||
trigger: '触发',
|
||||
update: '更新',
|
||||
updateSuccess: '更新成功',
|
||||
userCenter: '个人中心',
|
||||
myProfile: '个人信息',
|
||||
yesOrNo: {
|
||||
yes: '是',
|
||||
no: '否'
|
||||
@@ -158,7 +158,25 @@ const local: App.I18n.Schema = {
|
||||
404: '页面不存在',
|
||||
500: '服务器错误',
|
||||
'iframe-page': '外链页面',
|
||||
'user-center': '个人中心',
|
||||
workbench: '工作台',
|
||||
ticket: '工单',
|
||||
'ticket_my-submitted': '我提交的工单',
|
||||
'ticket_my-pending': '待我处理的工单',
|
||||
metrics: '效能度量',
|
||||
'metrics_project-progress': '项目进度',
|
||||
'metrics_member-efficiency': '员工能效',
|
||||
metrics_worktime: '工时统计',
|
||||
'personal-center': '个人中心',
|
||||
'personal-center_my-profile': '个人信息',
|
||||
'personal-center_my-item': '我的事项',
|
||||
'personal-center_my-weekly': '我的周报',
|
||||
'personal-center_my-monthly': '我的月报',
|
||||
'personal-center_my-performance': '我的绩效',
|
||||
'personal-center_my-application': '我的申请',
|
||||
'personal-center_pending-approval': '待我审批',
|
||||
infra: '基础设施',
|
||||
'infra_state-machine': '状态机管理',
|
||||
'infra_rd-code': '研发令号',
|
||||
function: '系统功能',
|
||||
function_tab: '标签页',
|
||||
'function_multi-tab': '多标签页',
|
||||
@@ -169,6 +187,18 @@ const local: App.I18n.Schema = {
|
||||
function_request: '请求',
|
||||
'function_toggle-auth': '切换权限',
|
||||
'function_super-page': '超级管理员可见',
|
||||
product: '产品管理',
|
||||
product_list: '产品列表',
|
||||
product_dashboard: '产品仪表盘',
|
||||
product_requirement: '需求池',
|
||||
product_setting: '产品设置',
|
||||
project: '项目管理',
|
||||
project_list: '项目列表',
|
||||
project_project: '项目详情',
|
||||
project_project_overview: '项目概览',
|
||||
project_project_requirement: '需求池',
|
||||
project_project_execution: '任务管理',
|
||||
project_project_setting: '项目设置',
|
||||
system: '系统管理',
|
||||
system_user: '用户管理',
|
||||
'system_user-detail': '用户详情',
|
||||
@@ -176,6 +206,7 @@ const local: App.I18n.Schema = {
|
||||
system_menu: '菜单管理',
|
||||
system_post: '岗位管理',
|
||||
system_dict: '字典管理',
|
||||
'system_user-management-relation': '管理链路',
|
||||
exception: '异常页',
|
||||
exception_403: '403',
|
||||
exception_404: '404',
|
||||
@@ -186,9 +217,6 @@ const local: App.I18n.Schema = {
|
||||
plugin_charts_echarts: 'ECharts',
|
||||
plugin_charts_antv: 'AntV',
|
||||
plugin_charts_vchart: 'VChart',
|
||||
plugin_editor: '编辑器',
|
||||
plugin_editor_quill: '富文本编辑器',
|
||||
plugin_editor_markdown: 'MD 编辑器',
|
||||
plugin_icon: '图标',
|
||||
plugin_map: '地图',
|
||||
plugin_print: '打印',
|
||||
@@ -342,6 +370,14 @@ const local: App.I18n.Schema = {
|
||||
status: {
|
||||
enable: '启用',
|
||||
disable: '禁用'
|
||||
},
|
||||
scopeType: {
|
||||
global: '全域',
|
||||
object: '对象域'
|
||||
},
|
||||
objectType: {
|
||||
product: '产品',
|
||||
project: '项目'
|
||||
}
|
||||
},
|
||||
role: {
|
||||
@@ -357,7 +393,7 @@ const local: App.I18n.Schema = {
|
||||
roleStatus: '角色状态',
|
||||
roleDesc: '角色描述',
|
||||
remark: '备注',
|
||||
sort: '显示顺序',
|
||||
sort: '排序',
|
||||
createTime: '创建时间',
|
||||
menuAuth: '菜单权限',
|
||||
buttonAuth: '按钮权限',
|
||||
@@ -365,6 +401,13 @@ const local: App.I18n.Schema = {
|
||||
selectedCount: '已选资源',
|
||||
disabledTip: '禁用角色不允许分配菜单权限',
|
||||
emptyRole: '请先选择角色',
|
||||
currentRoleCount: '当前角色数',
|
||||
globalRoleTitle: '全域角色',
|
||||
objectRoleTitle: '对象域角色模板',
|
||||
globalRoleSummary: '当前维护登录态全域角色及其资源授权关系。',
|
||||
objectRoleSummary: '当前维护对象域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
objectRoleSummaryProduct: '当前维护产品域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
objectRoleSummaryProject: '当前维护项目域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
lastAuthSave: '最近一次授权保存',
|
||||
unsavedTip: '授权变更后请记得保存',
|
||||
form: {
|
||||
@@ -373,7 +416,7 @@ const local: App.I18n.Schema = {
|
||||
roleStatus: '请选择角色状态',
|
||||
roleDesc: '请输入角色描述',
|
||||
remark: '请输入备注',
|
||||
sort: '请输入显示顺序',
|
||||
sort: '请输入排序',
|
||||
resourceKeyword: '输入资源名称过滤权限树',
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
@@ -403,7 +446,7 @@ const local: App.I18n.Schema = {
|
||||
emptyLeader: '暂无负责人',
|
||||
userName: '用户名',
|
||||
userGender: '性别',
|
||||
nickName: '昵称',
|
||||
nickName: '用户昵称',
|
||||
deptName: '所属组织',
|
||||
positionName: '岗位',
|
||||
userPhone: '手机号',
|
||||
@@ -421,7 +464,7 @@ const local: App.I18n.Schema = {
|
||||
form: {
|
||||
userName: '请输入用户名',
|
||||
userGender: '请选择性别',
|
||||
nickName: '请输入昵称',
|
||||
nickName: '请输入用户昵称',
|
||||
orgName: '请输入组织名称',
|
||||
orgCode: '请输入组织编码',
|
||||
orgTypeLabel: '请选择组织类型',
|
||||
@@ -463,7 +506,8 @@ const local: App.I18n.Schema = {
|
||||
orgType: {
|
||||
company: '公司',
|
||||
dept: '部门',
|
||||
direction: '条线',
|
||||
function: '职能部门',
|
||||
direction: '方向',
|
||||
team: '团队'
|
||||
},
|
||||
gender: {
|
||||
@@ -490,6 +534,7 @@ const local: App.I18n.Schema = {
|
||||
routeKind: '路由类型',
|
||||
routePropsJson: '路由参数 JSON',
|
||||
pageResource: '页面资源',
|
||||
boundRoute: '绑定路由',
|
||||
component: '组件路径',
|
||||
componentName: '组件名称',
|
||||
iframeUrl: 'iframe 地址',
|
||||
@@ -519,6 +564,36 @@ const local: App.I18n.Schema = {
|
||||
alwaysShow: '总是显示子菜单',
|
||||
createTime: '创建时间',
|
||||
topLevel: '顶级菜单',
|
||||
scopeType: '作用域',
|
||||
objectType: '对象类型',
|
||||
resourceCode: '资源编码',
|
||||
contextEyebrow: '菜单配置上下文',
|
||||
contextTitle: '统一作用域资源配置',
|
||||
contextDescription: '用同一套菜单页同时承接全域路由资源与对象域权限资源,避免为产品和项目再拆多套重复页面。',
|
||||
currentContext: '当前上下文',
|
||||
currentResourceCount: '当前资源数',
|
||||
editorMode: '编辑模式',
|
||||
editorModeGlobal: '路由型资源编辑器',
|
||||
editorModeObject: '对象导航编辑器',
|
||||
globalResourceTitle: '全域菜单资源',
|
||||
objectResourceTitle: '对象域资源',
|
||||
globalResourceSummary: '当前维护登录态菜单、路由映射与全局按钮权限资源。',
|
||||
objectResourceSummary: '当前维护对象域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于对象内权限点。',
|
||||
objectResourceSummaryProduct:
|
||||
'当前维护产品域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于产品对象内权限点。',
|
||||
objectResourceSummaryProject:
|
||||
'当前维护项目域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于项目对象内权限点。',
|
||||
scopeHintGlobal: '全域模式下保留现有路由型资源编辑方式,用于登录态菜单与全局按钮权限链路。',
|
||||
scopeHintObject: '对象域模式下维护导航项和操作按钮。导航项绑定真实页面资源,操作按钮只维护权限标识。',
|
||||
objectTypePlaceholder: '请选择对象类型',
|
||||
contextReady: '已选定配置范围',
|
||||
contextPending: '等待选择对象类型',
|
||||
objectTypeRequiredTitle: '请先选择对象类型',
|
||||
objectTypeRequiredDescription:
|
||||
'对象域资源必须先明确配置范围,例如产品或项目。选定后再加载树形资源列表和编辑弹层。',
|
||||
objectModeTipTitle: '对象域当前配置的是对象导航项和操作按钮',
|
||||
objectModeTipDescription:
|
||||
'第一版对象域菜单页只开放导航项和操作按钮。导航项通过绑定真实页面路由建立对象内头部导航,操作按钮只维护权限标识;暂不开放目录配置。',
|
||||
sections: {
|
||||
basic: '基础信息',
|
||||
route: '路由信息',
|
||||
@@ -530,6 +605,7 @@ const local: App.I18n.Schema = {
|
||||
parentId: '请选择父级菜单',
|
||||
menuName: '请输入菜单名称',
|
||||
permission: '请输入权限标识',
|
||||
resourceCode: '请输入资源编码',
|
||||
routeName: '请输入路由名称',
|
||||
routePath: '请输入路由路径',
|
||||
path: '请输入路由地址',
|
||||
@@ -537,6 +613,7 @@ const local: App.I18n.Schema = {
|
||||
componentName: '请输入组件名称',
|
||||
routeKind: '请选择路由类型',
|
||||
pageResource: '请选择页面资源',
|
||||
boundRoute: '请选择绑定路由',
|
||||
pageResourceParentMismatch: '所选页面资源与当前父级菜单层级不匹配',
|
||||
routePropsJson: '请输入合法的 JSON 字符串',
|
||||
routePropsJsonHint: '例如 {"url":"https://example.com"}',
|
||||
@@ -580,6 +657,8 @@ const local: App.I18n.Schema = {
|
||||
routePath: '路由地址填写访问路径中的末级段。以“角色管理”为例,完整地址是 /system/role,这里通常填写 role。',
|
||||
pageResource:
|
||||
'普通页面请从前端页面资源白名单中选择。例如角色管理对应 /system/role,与组件键 view.system_role。',
|
||||
boundRoute:
|
||||
'对象域导航项请绑定真实对象页面路由。例如产品域可绑定 /product/dashboard、/product/requirement 等页面。',
|
||||
component:
|
||||
'组件路径填写前端页面资源白名单中的组件键,不是 src 下的文件路径。以“角色管理”为例,可填写或选择 view.system_role。'
|
||||
},
|
||||
@@ -589,7 +668,9 @@ const local: App.I18n.Schema = {
|
||||
type: {
|
||||
directory: '目录',
|
||||
menu: '菜单',
|
||||
button: '按钮'
|
||||
button: '按钮',
|
||||
navigation: '导航项',
|
||||
actionButton: '操作按钮'
|
||||
},
|
||||
iconType: {
|
||||
iconify: 'iconify图标',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import './plugins/assets';
|
||||
import { setupDayjs, setupIconifyOffline, setupLoading, setupNProgress, setupUI } from './plugins';
|
||||
import { setupDirectives } from './directives';
|
||||
import { setupStore } from './store';
|
||||
import { setupRouter } from './router';
|
||||
import { setupI18n } from './locales';
|
||||
@@ -17,6 +18,8 @@ async function setupApp() {
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
setupDirectives(app);
|
||||
|
||||
setupUI(app);
|
||||
|
||||
setupStore(app);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { extend } from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
import { setDayjsLocale } from '../locales/dayjs';
|
||||
|
||||
export function setupDayjs() {
|
||||
extend(localeData);
|
||||
extend(isoWeek);
|
||||
|
||||
setDayjsLocale();
|
||||
}
|
||||
|
||||
@@ -28,13 +28,23 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
||||
function_tab: () => import("@/views/function/tab/index.vue"),
|
||||
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
|
||||
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
|
||||
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
||||
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
||||
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
||||
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
|
||||
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
|
||||
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
|
||||
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
|
||||
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
|
||||
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
|
||||
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
||||
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"),
|
||||
plugin_editor_quill: () => import("@/views/plugin/editor/quill/index.vue"),
|
||||
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
||||
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
||||
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
||||
@@ -47,11 +57,23 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
|
||||
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
|
||||
plugin_video: () => import("@/views/plugin/video/index.vue"),
|
||||
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
||||
product_list: () => import("@/views/product/list/index.vue"),
|
||||
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
||||
product_setting: () => import("@/views/product/setting/index.vue"),
|
||||
project_list: () => import("@/views/project/list/index.vue"),
|
||||
project_project_execution: () => import("@/views/project/project/execution/index.vue"),
|
||||
project_project_overview: () => import("@/views/project/project/overview/index.vue"),
|
||||
project_project_requirement: () => import("@/views/project/project/requirement/index.vue"),
|
||||
project_project_setting: () => import("@/views/project/project/setting/index.vue"),
|
||||
system_dict: () => import("@/views/system/dict/index.vue"),
|
||||
system_menu: () => import("@/views/system/menu/index.vue"),
|
||||
system_post: () => import("@/views/system/post/index.vue"),
|
||||
system_role: () => import("@/views/system/role/index.vue"),
|
||||
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
|
||||
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
|
||||
system_user: () => import("@/views/system/user/index.vue"),
|
||||
"user-center": () => import("@/views/user-center/index.vue"),
|
||||
"ticket_my-pending": () => import("@/views/ticket/my-pending/index.vue"),
|
||||
"ticket_my-submitted": () => import("@/views/ticket/my-submitted/index.vue"),
|
||||
workbench: () => import("@/views/workbench/index.vue"),
|
||||
};
|
||||
|
||||
@@ -170,6 +170,43 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'infra',
|
||||
path: '/infra',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'infra',
|
||||
i18nKey: 'route.infra',
|
||||
icon: 'ep:monitor',
|
||||
order: 20
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'infra_rd-code',
|
||||
path: '/infra/rd-code',
|
||||
component: 'view.infra_rd-code',
|
||||
meta: {
|
||||
title: 'infra_rd-code',
|
||||
i18nKey: 'route.infra_rd-code',
|
||||
icon: 'mdi:identifier',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'infra_state-machine',
|
||||
path: '/infra/state-machine',
|
||||
component: 'view.infra_state-machine',
|
||||
meta: {
|
||||
title: 'infra_state-machine',
|
||||
i18nKey: 'route.infra_state-machine',
|
||||
icon: 'mdi:state-machine',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
path: '/login/:module(pwd-login|reset-pwd)?',
|
||||
@@ -182,6 +219,152 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
path: '/metrics',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'metrics',
|
||||
i18nKey: 'route.metrics',
|
||||
icon: 'mdi:chart-line',
|
||||
order: 7
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'metrics_member-efficiency',
|
||||
path: '/metrics/member-efficiency',
|
||||
component: 'view.metrics_member-efficiency',
|
||||
meta: {
|
||||
title: 'metrics_member-efficiency',
|
||||
i18nKey: 'route.metrics_member-efficiency',
|
||||
icon: 'mdi:account-multiple-check-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'metrics_project-progress',
|
||||
path: '/metrics/project-progress',
|
||||
component: 'view.metrics_project-progress',
|
||||
meta: {
|
||||
title: 'metrics_project-progress',
|
||||
i18nKey: 'route.metrics_project-progress',
|
||||
icon: 'mdi:progress-clock',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'metrics_worktime',
|
||||
path: '/metrics/worktime',
|
||||
component: 'view.metrics_worktime',
|
||||
meta: {
|
||||
title: 'metrics_worktime',
|
||||
i18nKey: 'route.metrics_worktime',
|
||||
icon: 'mdi:clock-time-five-outline',
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'personal-center',
|
||||
path: '/personal-center',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'personal-center',
|
||||
i18nKey: 'route.personal-center',
|
||||
icon: 'mdi:account-circle-outline',
|
||||
order: 8
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'personal-center_my-application',
|
||||
path: '/personal-center/my-application',
|
||||
component: 'view.personal-center_my-application',
|
||||
meta: {
|
||||
title: 'personal-center_my-application',
|
||||
i18nKey: 'route.personal-center_my-application',
|
||||
icon: 'mdi:file-document-outline',
|
||||
order: 4,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-item',
|
||||
path: '/personal-center/my-item',
|
||||
component: 'view.personal-center_my-item',
|
||||
meta: {
|
||||
title: 'personal-center_my-item',
|
||||
i18nKey: 'route.personal-center_my-item',
|
||||
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-monthly',
|
||||
path: '/personal-center/my-monthly',
|
||||
component: 'view.personal-center_my-monthly',
|
||||
meta: {
|
||||
title: 'personal-center_my-monthly',
|
||||
i18nKey: 'route.personal-center_my-monthly',
|
||||
icon: 'mdi:calendar-month-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-performance',
|
||||
path: '/personal-center/my-performance',
|
||||
component: 'view.personal-center_my-performance',
|
||||
meta: {
|
||||
title: 'personal-center_my-performance',
|
||||
i18nKey: 'route.personal-center_my-performance',
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 3,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-profile',
|
||||
path: '/personal-center/my-profile',
|
||||
component: 'view.personal-center_my-profile',
|
||||
meta: {
|
||||
title: 'personal-center_my-profile',
|
||||
i18nKey: 'route.personal-center_my-profile',
|
||||
icon: 'mdi:account-box-outline',
|
||||
order: 0,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_my-weekly',
|
||||
path: '/personal-center/my-weekly',
|
||||
component: 'view.personal-center_my-weekly',
|
||||
meta: {
|
||||
title: 'personal-center_my-weekly',
|
||||
i18nKey: 'route.personal-center_my-weekly',
|
||||
icon: 'mdi:calendar-week-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_pending-approval',
|
||||
path: '/personal-center/pending-approval',
|
||||
component: 'view.personal-center_pending-approval',
|
||||
meta: {
|
||||
title: 'personal-center_pending-approval',
|
||||
i18nKey: 'route.personal-center_pending-approval',
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 5,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'plugin',
|
||||
path: '/plugin',
|
||||
@@ -254,37 +437,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
icon: 'mdi:clipboard-outline'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_editor',
|
||||
path: '/plugin/editor',
|
||||
meta: {
|
||||
title: 'plugin_editor',
|
||||
i18nKey: 'route.plugin_editor',
|
||||
icon: 'icon-park-outline:editor'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'plugin_editor_markdown',
|
||||
path: '/plugin/editor/markdown',
|
||||
component: 'view.plugin_editor_markdown',
|
||||
meta: {
|
||||
title: 'plugin_editor_markdown',
|
||||
i18nKey: 'route.plugin_editor_markdown',
|
||||
icon: 'ri:markdown-line'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'plugin_editor_quill',
|
||||
path: '/plugin/editor/quill',
|
||||
component: 'view.plugin_editor_quill',
|
||||
meta: {
|
||||
title: 'plugin_editor_quill',
|
||||
i18nKey: 'route.plugin_editor_quill',
|
||||
icon: 'mdi:file-document-edit-outline'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'plugin_excel',
|
||||
path: '/plugin/excel',
|
||||
@@ -430,6 +582,145 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
path: '/product',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'product',
|
||||
i18nKey: 'route.product',
|
||||
icon: 'carbon:product',
|
||||
order: 4
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'product_dashboard',
|
||||
path: '/product/dashboard',
|
||||
component: 'view.product_dashboard',
|
||||
meta: {
|
||||
title: 'product_dashboard',
|
||||
i18nKey: 'route.product_dashboard',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'product_list',
|
||||
path: '/product/list',
|
||||
component: 'view.product_list',
|
||||
meta: {
|
||||
title: 'product_list',
|
||||
i18nKey: 'route.product_list',
|
||||
icon: 'material-symbols:view-list-outline-rounded',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'product_requirement',
|
||||
path: '/product/requirement',
|
||||
component: 'view.product_requirement',
|
||||
meta: {
|
||||
title: 'product_requirement',
|
||||
i18nKey: 'route.product_requirement',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'product_setting',
|
||||
path: '/product/setting',
|
||||
component: 'view.product_setting',
|
||||
meta: {
|
||||
title: 'product_setting',
|
||||
i18nKey: 'route.product_setting',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'product_list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
path: '/project',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'project',
|
||||
i18nKey: 'route.project',
|
||||
icon: 'mdi:briefcase-outline',
|
||||
order: 5
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'project_list',
|
||||
path: '/project/list',
|
||||
component: 'view.project_list',
|
||||
meta: {
|
||||
title: 'project_list',
|
||||
i18nKey: 'route.project_list',
|
||||
icon: 'material-symbols:view-list-outline-rounded',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'project_project',
|
||||
path: '/project/project',
|
||||
meta: {
|
||||
title: 'project_project',
|
||||
i18nKey: 'route.project_project',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'project_project_execution',
|
||||
path: '/project/project/execution',
|
||||
component: 'view.project_project_execution',
|
||||
meta: {
|
||||
title: 'project_project_execution',
|
||||
i18nKey: 'route.project_project_execution',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'project_project_overview',
|
||||
path: '/project/project/overview',
|
||||
component: 'view.project_project_overview',
|
||||
meta: {
|
||||
title: 'project_project_overview',
|
||||
i18nKey: 'route.project_project_overview',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'project_project_requirement',
|
||||
path: '/project/project/requirement',
|
||||
component: 'view.project_project_requirement',
|
||||
meta: {
|
||||
title: 'project_project_requirement',
|
||||
i18nKey: 'route.project_project_requirement',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'project_project_setting',
|
||||
path: '/project/project/setting',
|
||||
component: 'view.project_project_setting',
|
||||
meta: {
|
||||
title: 'project_project_setting',
|
||||
i18nKey: 'route.project_project_setting',
|
||||
hideInMenu: true,
|
||||
activeMenu: 'project_list'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
path: '/system',
|
||||
@@ -512,17 +803,66 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
roles: ['R_ADMIN'],
|
||||
activeMenu: 'system_user'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'system_user-management-relation',
|
||||
path: '/system/user-management-relation',
|
||||
component: 'view.system_user-management-relation',
|
||||
meta: {
|
||||
title: 'system_user-management-relation',
|
||||
i18nKey: 'route.system_user-management-relation'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'user-center',
|
||||
path: '/user-center',
|
||||
component: 'layout.base$view.user-center',
|
||||
name: 'ticket',
|
||||
path: '/ticket',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'user-center',
|
||||
i18nKey: 'route.user-center',
|
||||
hideInMenu: true
|
||||
title: 'ticket',
|
||||
i18nKey: 'route.ticket',
|
||||
icon: 'mdi:ticket-confirmation-outline',
|
||||
order: 6
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'ticket_my-pending',
|
||||
path: '/ticket/my-pending',
|
||||
component: 'view.ticket_my-pending',
|
||||
meta: {
|
||||
title: 'ticket_my-pending',
|
||||
i18nKey: 'route.ticket_my-pending',
|
||||
icon: 'mdi:inbox-arrow-down-outline',
|
||||
order: 2,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ticket_my-submitted',
|
||||
path: '/ticket/my-submitted',
|
||||
component: 'view.ticket_my-submitted',
|
||||
meta: {
|
||||
title: 'ticket_my-submitted',
|
||||
i18nKey: 'route.ticket_my-submitted',
|
||||
icon: 'mdi:upload-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'workbench',
|
||||
path: '/workbench',
|
||||
component: 'layout.base$view.workbench',
|
||||
meta: {
|
||||
title: 'workbench',
|
||||
i18nKey: 'route.workbench',
|
||||
icon: 'mdi:view-dashboard-outline',
|
||||
order: 1,
|
||||
keepAlive: true,
|
||||
constant: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -181,7 +181,22 @@ const routeMap: RouteMap = {
|
||||
"function_tab": "/function/tab",
|
||||
"function_toggle-auth": "/function/toggle-auth",
|
||||
"iframe-page": "/iframe-page/:url",
|
||||
"infra": "/infra",
|
||||
"infra_rd-code": "/infra/rd-code",
|
||||
"infra_state-machine": "/infra/state-machine",
|
||||
"login": "/login/:module(pwd-login|reset-pwd)?",
|
||||
"metrics": "/metrics",
|
||||
"metrics_member-efficiency": "/metrics/member-efficiency",
|
||||
"metrics_project-progress": "/metrics/project-progress",
|
||||
"metrics_worktime": "/metrics/worktime",
|
||||
"personal-center": "/personal-center",
|
||||
"personal-center_my-application": "/personal-center/my-application",
|
||||
"personal-center_my-item": "/personal-center/my-item",
|
||||
"personal-center_my-monthly": "/personal-center/my-monthly",
|
||||
"personal-center_my-performance": "/personal-center/my-performance",
|
||||
"personal-center_my-profile": "/personal-center/my-profile",
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly",
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||
"plugin": "/plugin",
|
||||
"plugin_barcode": "/plugin/barcode",
|
||||
"plugin_charts": "/plugin/charts",
|
||||
@@ -189,9 +204,6 @@ const routeMap: RouteMap = {
|
||||
"plugin_charts_echarts": "/plugin/charts/echarts",
|
||||
"plugin_charts_vchart": "/plugin/charts/vchart",
|
||||
"plugin_copy": "/plugin/copy",
|
||||
"plugin_editor": "/plugin/editor",
|
||||
"plugin_editor_markdown": "/plugin/editor/markdown",
|
||||
"plugin_editor_quill": "/plugin/editor/quill",
|
||||
"plugin_excel": "/plugin/excel",
|
||||
"plugin_gantt": "/plugin/gantt",
|
||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
||||
@@ -206,6 +218,18 @@ const routeMap: RouteMap = {
|
||||
"plugin_tables_vtable": "/plugin/tables/vtable",
|
||||
"plugin_typeit": "/plugin/typeit",
|
||||
"plugin_video": "/plugin/video",
|
||||
"product": "/product",
|
||||
"product_dashboard": "/product/dashboard",
|
||||
"product_list": "/product/list",
|
||||
"product_requirement": "/product/requirement",
|
||||
"product_setting": "/product/setting",
|
||||
"project": "/project",
|
||||
"project_list": "/project/list",
|
||||
"project_project": "/project/project",
|
||||
"project_project_execution": "/project/project/execution",
|
||||
"project_project_overview": "/project/project/overview",
|
||||
"project_project_requirement": "/project/project/requirement",
|
||||
"project_project_setting": "/project/project/setting",
|
||||
"system": "/system",
|
||||
"system_dict": "/system/dict",
|
||||
"system_menu": "/system/menu",
|
||||
@@ -213,7 +237,11 @@ const routeMap: RouteMap = {
|
||||
"system_role": "/system/role",
|
||||
"system_user": "/system/user",
|
||||
"system_user-detail": "/system/user-detail/:id",
|
||||
"user-center": "/user-center"
|
||||
"system_user-management-relation": "/system/user-management-relation",
|
||||
"ticket": "/ticket",
|
||||
"ticket_my-pending": "/ticket/my-pending",
|
||||
"ticket_my-submitted": "/ticket/my-submitted",
|
||||
"workbench": "/workbench"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from 'vue-router';
|
||||
import type { RouteKey, RoutePath } from '@elegant-router/types';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { getRouteName } from '@/router/elegant/transform';
|
||||
@@ -51,6 +52,13 @@ export function createRouteGuard(router: Router) {
|
||||
|
||||
// 不需要登录的路由允许直接访问
|
||||
if (!needLogin) {
|
||||
const objectContextLocation = await handleObjectContextSwitch(to);
|
||||
|
||||
if (objectContextLocation) {
|
||||
next(objectContextLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRouteSwitch(to, from, next);
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +76,13 @@ export function createRouteGuard(router: Router) {
|
||||
}
|
||||
|
||||
// 正常放行
|
||||
const objectContextLocation = await handleObjectContextSwitch(to);
|
||||
|
||||
if (objectContextLocation) {
|
||||
next(objectContextLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRouteSwitch(to, from, next);
|
||||
});
|
||||
}
|
||||
@@ -176,6 +191,12 @@ function handleRouteSwitch(to: RouteLocationNormalized, from: RouteLocationNorma
|
||||
next();
|
||||
}
|
||||
|
||||
async function handleObjectContextSwitch(to: RouteLocationNormalized) {
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
return objectContextStore.ensureContextByRoute(to);
|
||||
}
|
||||
|
||||
function getRouteQueryOfLoginRoute(to: RouteLocationNormalized, routeHome: RouteKey) {
|
||||
const loginRoute: RouteKey = 'login';
|
||||
const redirect = to.fullPath;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'vue-router';
|
||||
import { createBuiltinVueRoutes } from './routes/builtin';
|
||||
import { createRouterGuard } from './guard';
|
||||
import { setGlobalRouter } from './instance';
|
||||
|
||||
const { VITE_ROUTER_HISTORY_MODE = 'history', VITE_BASE_URL } = import.meta.env;
|
||||
|
||||
@@ -22,6 +23,8 @@ export const router = createRouter({
|
||||
routes: createBuiltinVueRoutes()
|
||||
});
|
||||
|
||||
setGlobalRouter(router);
|
||||
|
||||
/** 挂载并初始化 Vue Router */
|
||||
export async function setupRouter(app: App) {
|
||||
app.use(router);
|
||||
|
||||
15
src/router/instance.ts
Normal file
15
src/router/instance.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
let globalRouter: Router | null = null;
|
||||
|
||||
export function setGlobalRouter(router: Router) {
|
||||
globalRouter = router;
|
||||
}
|
||||
|
||||
export function getGlobalRouter() {
|
||||
if (!globalRouter) {
|
||||
throw new Error('Global router is not initialized');
|
||||
}
|
||||
|
||||
return globalRouter;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { clearUserRouteCache } from './route';
|
||||
import type { ServiceRequestResult } from './shared';
|
||||
import { type ServiceRequestResult, mapServiceResult, normalizeStringId } from './shared';
|
||||
|
||||
/** 后端登录返回 */
|
||||
interface BackendLoginToken {
|
||||
@@ -14,10 +14,38 @@ interface BackendLoginToken {
|
||||
interface BackendUserInfoDTO {
|
||||
userId: string | number;
|
||||
userName?: string | null;
|
||||
nickname?: string | null;
|
||||
roles?: string[] | null;
|
||||
buttons?: string[] | null;
|
||||
}
|
||||
|
||||
interface BackendMyProfileDetailDTO {
|
||||
id?: string | number | null;
|
||||
userId?: string | number | null;
|
||||
username?: string | null;
|
||||
userName?: string | null;
|
||||
nickname?: string | null;
|
||||
company?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
loginIp?: string | null;
|
||||
loginDate?: string | null;
|
||||
createTime?: string | null;
|
||||
roles?: Api.SystemManage.RoleSimple[] | null;
|
||||
dept?: Api.SystemManage.DeptSimple | null;
|
||||
position?: Api.SystemManage.PostSimple | null;
|
||||
}
|
||||
|
||||
interface BackendFileDTO {
|
||||
id: string | number;
|
||||
configId: string | number;
|
||||
name?: string | null;
|
||||
path: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
|
||||
|
||||
/** 将后端 token 结构转换成前端现有结构 */
|
||||
@@ -32,11 +60,48 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
|
||||
return {
|
||||
userId: String(data.userId ?? ''),
|
||||
userName: data.userName ?? '',
|
||||
nickname: data.nickname ?? '',
|
||||
roles: data.roles ?? [],
|
||||
buttons: data.buttons ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function safeStringId(value: string | number | null | undefined): string | null {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
function mapMyProfileDetail(data: BackendMyProfileDetailDTO, fallbackUserId = ''): Api.Auth.MyProfileDetail {
|
||||
const baseInfo = {
|
||||
userId: String(data.id ?? data.userId ?? fallbackUserId ?? ''),
|
||||
username: data.username ?? data.userName ?? '',
|
||||
nickname: data.nickname ?? '',
|
||||
deptId: safeStringId(data.dept?.id),
|
||||
deptName: data.dept?.name ?? '',
|
||||
positionId: safeStringId(data.position?.id),
|
||||
positionName: data.position?.name ?? ''
|
||||
};
|
||||
|
||||
const contactInfo = {
|
||||
company: data.company ?? null,
|
||||
email: data.email ?? '',
|
||||
mobile: data.mobile ?? '',
|
||||
sex: data.sex ?? 0,
|
||||
avatar: data.avatar ?? ''
|
||||
};
|
||||
|
||||
const extraInfo = {
|
||||
roles: data.roles ?? [],
|
||||
dept: data.dept ?? null,
|
||||
position: data.position ?? null,
|
||||
loginIp: data.loginIp ?? '',
|
||||
loginDate: data.loginDate ?? null,
|
||||
createTime: data.createTime ?? null
|
||||
};
|
||||
|
||||
return { ...baseInfo, ...contactInfo, ...extraInfo };
|
||||
}
|
||||
|
||||
export function clearUserInfoCache() {
|
||||
userInfoPromise = null;
|
||||
}
|
||||
@@ -99,19 +164,88 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取当前登录人资料详情 */
|
||||
export async function fetchGetMyProfileDetail(
|
||||
options: {
|
||||
userId?: string;
|
||||
} = {}
|
||||
): Promise<ServiceRequestResult<Api.Auth.MyProfileDetail>> {
|
||||
const result = await request<BackendMyProfileDetailDTO>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/get`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as ServiceRequestResult<Api.Auth.MyProfileDetail>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: mapMyProfileDetail(result.data, options.userId ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新当前登录人基础资料 */
|
||||
export function fetchUpdateMyProfile(data: Api.Auth.UpdateMyProfileParams) {
|
||||
return request<boolean>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改当前登录人密码 */
|
||||
export async function fetchUpdateMyAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const result = await request<BackendFileDTO>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-avatar`,
|
||||
method: 'put',
|
||||
data: formData
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<BackendFileDTO>, data => ({
|
||||
...data,
|
||||
id: normalizeStringId(data.id),
|
||||
configId: normalizeStringId(data.configId)
|
||||
}));
|
||||
}
|
||||
|
||||
export function fetchUpdateMyPassword(data: Api.Auth.UpdateMyPasswordParams) {
|
||||
return request<boolean>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-password`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*
|
||||
* @param refreshToken 刷新 token
|
||||
*/
|
||||
export function fetchRefreshToken(refreshToken: string) {
|
||||
return request<Api.Auth.LoginToken>({
|
||||
export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<Api.Auth.LoginToken>> {
|
||||
// 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded
|
||||
// skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization,不看 PermitAll)
|
||||
const result = await request<BackendLoginToken>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
|
||||
method: 'post',
|
||||
data: {
|
||||
refreshToken
|
||||
}
|
||||
params: { refreshToken },
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
skipAuth: true,
|
||||
suppressErrorMessage: true,
|
||||
skipTokenRefresh: true
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as ServiceRequestResult<Api.Auth.LoginToken>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: mapLoginToken(result.data)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,14 @@ export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取前端运行时字典缓存 */
|
||||
export function fetchGetFrontendDictCache() {
|
||||
return request<Api.Dict.FrontendDictCache>({
|
||||
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建字典数据 */
|
||||
export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
|
||||
return request<number>({
|
||||
@@ -102,3 +110,11 @@ export function fetchBatchDeleteDictData(ids: number[]) {
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/** 通过岗位编码获取该字典的所有字典数据 */
|
||||
export function fetchGetDictDataByCode(code: string) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
||||
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
88
src/service/api/file.ts
Normal file
88
src/service/api/file.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||
|
||||
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
|
||||
|
||||
/**
|
||||
* 拼接文件永久代理路径,用于富文本 <img src>。
|
||||
*
|
||||
* 后端 GET 接口匿名访问、Content-Disposition: inline,私有桶下也不会过期。
|
||||
* 调用方拿到上传响应里的 configId + path 后直接调用本函数得到可写入 HTML 的 url。
|
||||
*/
|
||||
export function buildFileProxyUrl(configId: string, path: string) {
|
||||
return `${FILE_PREFIX}/${configId}/get/${encodeURI(path)}`;
|
||||
}
|
||||
|
||||
export interface UploadFileResult {
|
||||
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
|
||||
id: string;
|
||||
/** 对象存储配置编号(字符串形式),与 path 一起拼接永久代理路径 */
|
||||
configId: string;
|
||||
/** 文件相对路径(含日期目录、文件名),与 configId 一起拼接永久代理路径 */
|
||||
path: string;
|
||||
/**
|
||||
* 文件访问 URL:私有桶带签名(24h 过期)、公开桶裸 URL。
|
||||
* ⚠️ 仅供后端调试 / 历史兼容,禁止写进富文本 <img src> —— 会随签名过期导致回显失效。
|
||||
* 富文本图片请用 buildFileProxyUrl(configId, path) 的返回值。
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
type UploadFileResponse = {
|
||||
id: string | number;
|
||||
configId: string | number;
|
||||
path: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/** 上传文件(模式一:后端中转) */
|
||||
export async function uploadFile(file: File, directory?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (directory) {
|
||||
formData.append('directory', directory);
|
||||
}
|
||||
|
||||
const result = await request<UploadFileResponse>({
|
||||
url: `${FILE_PREFIX}/upload`,
|
||||
method: 'post',
|
||||
data: formData
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<UploadFileResponse>, data => ({
|
||||
id: String(data.id),
|
||||
configId: String(data.configId),
|
||||
path: data.path,
|
||||
url: data.url
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* 业务表单"取消/关闭/标记删除"场景调用本接口清理孤儿文件。
|
||||
* 删除已不存在的文件(后端返回错误码 `1001003001`)应由调用方视为成功并吞掉。
|
||||
*/
|
||||
export function deleteFile(id: string) {
|
||||
return request<boolean>({
|
||||
url: `${FILE_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件(流)
|
||||
*
|
||||
* 走后端代理接口 `/system/file/download?id=xxx`,由后端读取对象存储并以字节流返回。
|
||||
* 私有桶下不要直接打开 `infra_file.url`,签名地址会过期。
|
||||
*/
|
||||
export function downloadFile(id: string) {
|
||||
return request<Blob, 'blob'>({
|
||||
url: `${FILE_PREFIX}/download`,
|
||||
method: 'get',
|
||||
params: { id },
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
export * from './auth';
|
||||
export * from './dict';
|
||||
export * from './file';
|
||||
export * from './infra';
|
||||
export * from './object-context';
|
||||
export * from './personal-item';
|
||||
export * from './product';
|
||||
export * from './project';
|
||||
export * from './project-shared';
|
||||
export * from './route';
|
||||
export * from './system-manage';
|
||||
|
||||
208
src/service/api/infra.ts
Normal file
208
src/service/api/infra.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||
|
||||
const OBJECT_STATUS_MODEL_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/model`;
|
||||
const OBJECT_STATUS_TRANSITION_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/transition`;
|
||||
|
||||
type ObjectStatusModelResponse = Omit<
|
||||
Api.Infra.ObjectStatusModel,
|
||||
| 'id'
|
||||
| 'initialFlag'
|
||||
| 'terminalFlag'
|
||||
| 'allowEdit'
|
||||
| 'progressExcludedFlag'
|
||||
| 'allowCreateProject'
|
||||
| 'allowCreateRequirement'
|
||||
> & {
|
||||
id: string | number;
|
||||
initialFlag: boolean | number | string | null | undefined;
|
||||
terminalFlag: boolean | number | string | null | undefined;
|
||||
allowEdit: boolean | number | string | null | undefined;
|
||||
progressExcludedFlag: boolean | number | string | null | undefined;
|
||||
allowCreateProject: boolean | number | string | null | undefined;
|
||||
allowCreateRequirement: boolean | number | string | null | undefined;
|
||||
};
|
||||
|
||||
type ObjectStatusTransitionResponse = Omit<Api.Infra.ObjectStatusTransition, 'id' | 'needReason'> & {
|
||||
id: string | number;
|
||||
needReason: boolean | number | string | null | undefined;
|
||||
};
|
||||
|
||||
type ObjectStatusModelPageResponse = Api.Infra.PageResult<ObjectStatusModelResponse>;
|
||||
|
||||
type ObjectStatusTransitionPageResponse = Api.Infra.PageResult<ObjectStatusTransitionResponse>;
|
||||
|
||||
function createBatchDeleteQuery(ids: string[]) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
ids.forEach(id => {
|
||||
query.append('ids', id);
|
||||
});
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeObjectStatusModel(model: ObjectStatusModelResponse): Api.Infra.ObjectStatusModel {
|
||||
return {
|
||||
...model,
|
||||
id: normalizeStringId(model.id),
|
||||
initialFlag: normalizeBooleanFlag(model.initialFlag),
|
||||
terminalFlag: normalizeBooleanFlag(model.terminalFlag),
|
||||
allowEdit: normalizeBooleanFlag(model.allowEdit),
|
||||
progressExcludedFlag: normalizeBooleanFlag(model.progressExcludedFlag),
|
||||
allowCreateProject: normalizeBooleanFlag(model.allowCreateProject),
|
||||
allowCreateRequirement: normalizeBooleanFlag(model.allowCreateRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObjectStatusTransition(transition: ObjectStatusTransitionResponse): Api.Infra.ObjectStatusTransition {
|
||||
return {
|
||||
...transition,
|
||||
id: normalizeStringId(transition.id),
|
||||
needReason: normalizeBooleanFlag(transition.needReason)
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGetObjectStatusModelPage(params?: Api.Infra.ObjectStatusModelSearchParams) {
|
||||
const result = await request<ObjectStatusModelPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeObjectStatusModel)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGetObjectStatusModel(id: string) {
|
||||
const result = await request<ObjectStatusModelResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelResponse>, normalizeObjectStatusModel);
|
||||
}
|
||||
|
||||
export async function fetchCreateObjectStatusModel(data: Api.Infra.SaveObjectStatusModelParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function fetchUpdateObjectStatusModel(data: { id: string } & Api.Infra.SaveObjectStatusModelParams) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeleteObjectStatusModel(id: string) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBatchDeleteObjectStatusModel(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGetObjectStatusTransitionPage(params?: Api.Infra.ObjectStatusTransitionSearchParams) {
|
||||
const result = await request<ObjectStatusTransitionPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ObjectStatusTransitionPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeObjectStatusTransition)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGetObjectStatusTransition(id: string) {
|
||||
const result = await request<ObjectStatusTransitionResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ObjectStatusTransitionResponse>,
|
||||
normalizeObjectStatusTransition
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCreateObjectStatusTransition(data: Api.Infra.SaveObjectStatusTransitionParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function fetchUpdateObjectStatusTransition(data: { id: string } & Api.Infra.SaveObjectStatusTransitionParams) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeleteObjectStatusTransition(id: string) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBatchDeleteObjectStatusTransition(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
199
src/service/api/object-context-normalize.ts
Normal file
199
src/service/api/object-context-normalize.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||
|
||||
export interface BackendObjectContextMenuDTO {
|
||||
key?: string | null;
|
||||
label?: string | null;
|
||||
routeKey?: string | null;
|
||||
routePath?: string | null;
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
path?: string | null;
|
||||
icon?: string | null;
|
||||
sort?: number | null;
|
||||
children?: BackendObjectContextMenuDTO[] | null;
|
||||
}
|
||||
|
||||
interface BackendProductContextProductDTO {
|
||||
id?: string | number | null;
|
||||
code?: string | null;
|
||||
directionCode?: string | null;
|
||||
name?: string | null;
|
||||
managerUserId?: string | number | null;
|
||||
statusCode?: string | null;
|
||||
}
|
||||
|
||||
interface BackendProjectContextProjectDTO {
|
||||
id?: string | number | null;
|
||||
projectCode?: string | null;
|
||||
projectName?: string | null;
|
||||
projectType?: string | null;
|
||||
productId?: string | number | null;
|
||||
managerUserId?: string | number | null;
|
||||
statusCode?: string | null;
|
||||
}
|
||||
|
||||
interface BackendObjectContextRoleDTO {
|
||||
roleId?: string | number | null;
|
||||
roleCode?: string | null;
|
||||
roleName?: string | null;
|
||||
guestFlag?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BackendObjectContextDTO {
|
||||
domainKey?: string | null;
|
||||
objectType?: string | null;
|
||||
objectId?: string | number | null;
|
||||
objectName?: string | null;
|
||||
objectSummary?: Record<string, unknown> | null;
|
||||
menus?: BackendObjectContextMenuDTO[] | null;
|
||||
contextScopedMenus?: BackendObjectContextMenuDTO[] | null;
|
||||
buttonCodes?: string[] | null;
|
||||
currentProduct?: BackendProductContextProductDTO | null;
|
||||
currentProject?: BackendProjectContextProjectDTO | null;
|
||||
currentRole?: BackendObjectContextRoleDTO | null;
|
||||
navs?: BackendObjectContextMenuDTO[] | null;
|
||||
buttons?: string[] | null;
|
||||
defaultRouteKey?: string | null;
|
||||
defaultRoutePath?: string | null;
|
||||
}
|
||||
|
||||
function normalizeString(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function normalizeRoutePath(path: string | null | undefined) {
|
||||
const normalizedPath = normalizeString(path).trim();
|
||||
|
||||
if (!normalizedPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/')) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return `/${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizeCurrentProduct(
|
||||
product: BackendProductContextProductDTO
|
||||
): Record<'id' | 'code' | 'directionCode' | 'name' | 'managerUserId' | 'statusCode', string> {
|
||||
return {
|
||||
id: normalizeStringId(product.id || ''),
|
||||
code: normalizeString(product.code),
|
||||
directionCode: normalizeString(product.directionCode),
|
||||
name: normalizeString(product.name),
|
||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
|
||||
statusCode: normalizeString(product.statusCode)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCurrentProject(project: BackendProjectContextProjectDTO) {
|
||||
return {
|
||||
id: normalizeStringId(project.id || ''),
|
||||
projectCode: normalizeString(project.projectCode),
|
||||
projectName: normalizeString(project.projectName),
|
||||
projectType: normalizeString(project.projectType),
|
||||
productId: normalizeNullableStringId(project.productId),
|
||||
managerUserId: normalizeNullableStringId(project.managerUserId) ?? '',
|
||||
statusCode: normalizeString(project.statusCode)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCurrentRole(role: BackendObjectContextRoleDTO) {
|
||||
return {
|
||||
roleId: normalizeStringId(role.roleId || ''),
|
||||
roleCode: normalizeString(role.roleCode),
|
||||
roleName: normalizeString(role.roleName),
|
||||
guestFlag: Boolean(role.guestFlag)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMenu(menu: BackendObjectContextMenuDTO): App.ObjectContext.Menu {
|
||||
const routeKey = normalizeString(menu.routeKey);
|
||||
const routePath = normalizeRoutePath(menu.routePath || menu.path);
|
||||
const key = normalizeString(menu.key || routeKey || routePath || menu.id);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: normalizeString(menu.label || menu.name),
|
||||
routeKey: routeKey || null,
|
||||
routePath: routePath || null,
|
||||
children: menu.children?.map(child => normalizeMenu(child)) || []
|
||||
};
|
||||
}
|
||||
|
||||
function getFirstNonEmptyMenuSource(data: BackendObjectContextDTO) {
|
||||
const menuSources = [data.contextScopedMenus, data.menus, data.navs];
|
||||
|
||||
return menuSources.find(source => Array.isArray(source) && source.length > 0) || [];
|
||||
}
|
||||
|
||||
function getFirstRoutableMenu(menus: App.ObjectContext.Menu[]): App.ObjectContext.Menu | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.routeKey || menu.routePath) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
const firstChildMenu = menu.children?.length ? getFirstRoutableMenu(menu.children) : null;
|
||||
|
||||
if (firstChildMenu) {
|
||||
return firstChildMenu;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeObjectSummary(data: BackendObjectContextDTO): App.ObjectContext.Summary | null {
|
||||
if (data.objectSummary) {
|
||||
return data.objectSummary;
|
||||
}
|
||||
|
||||
const summary: App.ObjectContext.Summary = {};
|
||||
|
||||
if (data.currentProduct) {
|
||||
summary.currentProduct = normalizeCurrentProduct(data.currentProduct);
|
||||
}
|
||||
|
||||
if (data.currentProject) {
|
||||
summary.currentProject = normalizeCurrentProject(data.currentProject);
|
||||
}
|
||||
|
||||
if (data.currentRole !== undefined) {
|
||||
summary.currentRole = data.currentRole ? normalizeCurrentRole(data.currentRole) : null;
|
||||
}
|
||||
|
||||
return Object.keys(summary).length ? summary : null;
|
||||
}
|
||||
|
||||
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
|
||||
// eslint-disable-next-line complexity
|
||||
export function normalizeObjectContext(
|
||||
config: App.ObjectContext.DomainConfig,
|
||||
objectId: string,
|
||||
data: BackendObjectContextDTO
|
||||
): Api.ObjectContext.ContextInfo {
|
||||
const rawMenus = getFirstNonEmptyMenuSource(data);
|
||||
const contextScopedMenus = rawMenus.map(menu => normalizeMenu(menu));
|
||||
const firstRoutableMenu = getFirstRoutableMenu(contextScopedMenus);
|
||||
const currentProduct = data.currentProduct ? normalizeCurrentProduct(data.currentProduct) : null;
|
||||
const currentProject = data.currentProject ? normalizeCurrentProject(data.currentProject) : null;
|
||||
|
||||
return {
|
||||
domainKey: (data.domainKey || config.domainKey) as App.ObjectContext.DomainKey,
|
||||
objectType: (data.objectType || config.objectType) as App.ObjectContext.ObjectType,
|
||||
objectId: normalizeString(data.objectId) || currentProduct?.id || currentProject?.id || objectId,
|
||||
objectName: normalizeString(data.objectName || currentProduct?.name || currentProject?.projectName),
|
||||
objectSummary: normalizeObjectSummary(data),
|
||||
contextScopedMenus,
|
||||
buttonCodes: data.buttonCodes ?? data.buttons ?? [],
|
||||
defaultRouteKey: data.defaultRouteKey || firstRoutableMenu?.routeKey || '',
|
||||
defaultRoutePath:
|
||||
normalizeRoutePath(data.defaultRoutePath) || firstRoutableMenu?.routePath || config.fallbackDefaultRoutePath
|
||||
};
|
||||
}
|
||||
40
src/service/api/object-context.ts
Normal file
40
src/service/api/object-context.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LocationQueryValue } from 'vue-router';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, safeJsonRequestConfig } from './shared';
|
||||
import { type BackendObjectContextDTO, normalizeObjectContext } from './object-context-normalize';
|
||||
|
||||
function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: string) {
|
||||
if (config.contextApiObjectIdPlacement !== 'path') {
|
||||
return config.contextApiPath;
|
||||
}
|
||||
|
||||
const placeholder = `{${config.contextApiObjectIdParamKey}}`;
|
||||
|
||||
return config.contextApiPath.replace(placeholder, encodeURIComponent(objectId));
|
||||
}
|
||||
|
||||
export async function fetchGetObjectContext(
|
||||
config: App.ObjectContext.DomainConfig,
|
||||
objectId: string
|
||||
): Promise<ServiceRequestResult<Api.ObjectContext.ContextInfo>> {
|
||||
const result = await request<BackendObjectContextDTO>({
|
||||
...safeJsonRequestConfig,
|
||||
url: createContextApiUrl(config, objectId),
|
||||
method: 'get',
|
||||
params:
|
||||
config.contextApiObjectIdPlacement === 'path'
|
||||
? undefined
|
||||
: ({
|
||||
[config.contextApiObjectIdParamKey]: objectId
|
||||
} satisfies Record<string, LocationQueryValue | LocationQueryValue[]>)
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as ServiceRequestResult<Api.ObjectContext.ContextInfo>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: normalizeObjectContext(config, objectId, result.data)
|
||||
};
|
||||
}
|
||||
880
src/service/api/personal-item.ts
Normal file
880
src/service/api/personal-item.ts
Normal file
@@ -0,0 +1,880 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type { ConfigType } from 'dayjs';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import {
|
||||
type ProjectExecutionResponse,
|
||||
type TaskWorklogResponse,
|
||||
normalizeProjectLocalDate,
|
||||
normalizeTaskWorklog
|
||||
} from './project-shared';
|
||||
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||
|
||||
type PersonalItemRecord = Api.PersonalItem.PersonalItem;
|
||||
type PersonalItemWorklogRecord = Api.Project.TaskWorklog;
|
||||
type PersonalItemResult<T> = Promise<FlatResponseData<any, T>>;
|
||||
type StringIdResponse = string | number;
|
||||
type PersonalItemLocalDateValue = string | number[] | null;
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: StringIdResponse;
|
||||
id?: StringIdResponse;
|
||||
};
|
||||
type PersonalItemLifecycleActionResponse = Omit<Api.PersonalItem.PersonalItemLifecycleAction, 'needReason'> & {
|
||||
needReason?: boolean | number | string | null;
|
||||
};
|
||||
type PersonalItemResponse = Omit<
|
||||
Api.PersonalItem.PersonalItem,
|
||||
| 'id'
|
||||
| 'ownerId'
|
||||
| 'terminal'
|
||||
| 'allowEdit'
|
||||
| 'availableActions'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'attachments'
|
||||
| 'totalSpentHours'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
ownerId: StringIdResponse;
|
||||
terminal?: boolean | number | string | null;
|
||||
allowEdit?: boolean | number | string | null;
|
||||
availableActions?: PersonalItemLifecycleActionResponse[] | null;
|
||||
plannedStartDate?: PersonalItemLocalDateValue;
|
||||
plannedEndDate?: PersonalItemLocalDateValue;
|
||||
actualStartDate?: PersonalItemLocalDateValue;
|
||||
actualEndDate?: PersonalItemLocalDateValue;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
progressRate?: number | null;
|
||||
totalSpentHours?: number | string | null;
|
||||
};
|
||||
type PersonalItemPageResponse = Omit<Api.PersonalItem.PersonalItemPageResult, 'total' | 'list'> & {
|
||||
total: number | string;
|
||||
list: PersonalItemResponse[];
|
||||
};
|
||||
type PersonalItemWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
|
||||
type PersonalItemExecutionOptionResponse = ProjectExecutionResponse & {
|
||||
projectName?: string | null;
|
||||
};
|
||||
type PersonalItemSaveRequest = {
|
||||
executionId?: string;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
progressRate?: number;
|
||||
plannedStartDate?: string;
|
||||
plannedEndDate?: string;
|
||||
taskDesc?: string;
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
}>;
|
||||
};
|
||||
type PersonalItemWorklogSaveRequest = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
durationHours: number;
|
||||
progressRate: number;
|
||||
workContent?: string;
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
}>;
|
||||
difficulty: string;
|
||||
};
|
||||
|
||||
const PERSONAL_ITEM_PREFIX = `${WEB_SERVICE_PREFIX}/project/personal-items`;
|
||||
|
||||
const CURRENT_USER_ID = 'current-user';
|
||||
const CURRENT_USER_NAME = '当前用户';
|
||||
|
||||
const personalItems: PersonalItemRecord[] = createSeedItems();
|
||||
const personalItemWorklogs: PersonalItemWorklogRecord[] = createSeedWorklogs();
|
||||
const executionOptions: Api.PersonalItem.PersonalItemExecutionOption[] = createExecutionOptions();
|
||||
|
||||
function createSuccessResult<T>(data: T): PersonalItemResult<T> {
|
||||
return Promise.resolve({
|
||||
data,
|
||||
error: null,
|
||||
response: undefined
|
||||
} as unknown as FlatResponseData<any, T>);
|
||||
}
|
||||
|
||||
function normalizePageTotal(total: number | string) {
|
||||
const value = Number(total);
|
||||
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list.map(item => {
|
||||
const rawId = item.fileId ?? item.id;
|
||||
|
||||
return {
|
||||
...item,
|
||||
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeLifecycleActions(
|
||||
actions?: PersonalItemLifecycleActionResponse[] | null
|
||||
): Api.PersonalItem.PersonalItemLifecycleAction[] {
|
||||
return (actions ?? []).map(action => ({
|
||||
actionCode: action.actionCode,
|
||||
actionName: action.actionName ?? '',
|
||||
needReason: normalizeBooleanFlag(action.needReason)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePersonalItem(response: PersonalItemResponse): Api.PersonalItem.PersonalItem {
|
||||
return {
|
||||
id: normalizeStringId(response.id),
|
||||
taskTitle: response.taskTitle ?? '',
|
||||
type: response.type ?? '',
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
statusCode: response.statusCode,
|
||||
terminal: normalizeBooleanFlag(response.terminal),
|
||||
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||
progressRate:
|
||||
typeof response.progressRate === 'number' ? response.progressRate : Number(response.progressRate ?? 0),
|
||||
totalSpentHours: (() => {
|
||||
if (typeof response.totalSpentHours === 'number') {
|
||||
return response.totalSpentHours;
|
||||
}
|
||||
|
||||
if (response.totalSpentHours === null || response.totalSpentHours === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(response.totalSpentHours);
|
||||
})(),
|
||||
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
taskDesc: response.taskDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null,
|
||||
attachments: normalizeAttachments(response.attachments),
|
||||
creator: response.creator ?? '',
|
||||
createTime: response.createTime ?? '',
|
||||
updater: response.updater ?? '',
|
||||
updateTime: response.updateTime ?? '',
|
||||
deleted: Boolean(response.deleted),
|
||||
ownerName: response.ownerName ?? null,
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
statusName: response.statusName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersonalItemExecutionOption(
|
||||
response: PersonalItemExecutionOptionResponse
|
||||
): Api.PersonalItem.PersonalItemExecutionOption {
|
||||
return {
|
||||
executionId: normalizeStringId(response.id),
|
||||
executionName: response.executionName ?? '',
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
projectName: response.projectName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function toPersonalItemSaveRequest(data: Api.PersonalItem.SavePersonalItemParams): PersonalItemSaveRequest {
|
||||
return {
|
||||
executionId: data.executionId ?? undefined,
|
||||
taskTitle: data.taskTitle.trim(),
|
||||
type: data.type,
|
||||
progressRate: typeof data.progressRate === 'number' ? data.progressRate : undefined,
|
||||
plannedStartDate: data.plannedStartDate ?? undefined,
|
||||
plannedEndDate: data.plannedEndDate ?? undefined,
|
||||
taskDesc: data.taskDesc ?? undefined,
|
||||
attachments:
|
||||
data.attachments?.map(item => ({
|
||||
id: item.fileId || undefined,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
contentType: item.contentType
|
||||
})) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function toPersonalItemWorklogSaveRequest(
|
||||
data: Api.PersonalItem.SavePersonalItemWorklogParams
|
||||
): PersonalItemWorklogSaveRequest {
|
||||
return {
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
durationHours: Number(data.durationHours.toFixed(1)),
|
||||
progressRate: Number(data.progressRate.toFixed(2)),
|
||||
workContent: data.workContent ?? undefined,
|
||||
attachments:
|
||||
data.attachments?.map(item => ({
|
||||
id: item.fileId || undefined,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
size: item.size,
|
||||
contentType: item.contentType
|
||||
})) ?? undefined,
|
||||
difficulty: data.difficulty
|
||||
};
|
||||
}
|
||||
|
||||
function createPersonalItemPageQuery(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo ?? 1));
|
||||
query.append('pageSize', String(params.pageSize ?? 10));
|
||||
|
||||
if (params.keyword) {
|
||||
query.append('keyword', params.keyword);
|
||||
}
|
||||
|
||||
if (params.ownerId) {
|
||||
query.append('ownerId', params.ownerId);
|
||||
}
|
||||
|
||||
if (params.statusCode) {
|
||||
query.append('statusCode', params.statusCode);
|
||||
}
|
||||
|
||||
params.updateTime?.forEach(item => {
|
||||
if (item) {
|
||||
query.append('updateTime', item);
|
||||
}
|
||||
});
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function createIdsQuery(ids: string[]) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
ids.forEach(id => {
|
||||
if (id) {
|
||||
query.append('ids', id);
|
||||
}
|
||||
});
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function createBindExecutionQuery(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
payload.ids.forEach(id => {
|
||||
if (id) {
|
||||
query.append('itemIds', id);
|
||||
}
|
||||
});
|
||||
query.append('executionId', payload.executionId);
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function cloneAttachment(item: Api.Project.AttachmentItem): Api.Project.AttachmentItem {
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
function cloneItem(item: PersonalItemRecord): PersonalItemRecord {
|
||||
return {
|
||||
...item,
|
||||
attachments: item.attachments?.map(cloneAttachment) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function cloneWorklog(item: PersonalItemWorklogRecord): PersonalItemWorklogRecord {
|
||||
return {
|
||||
...item,
|
||||
attachments: item.attachments?.map(cloneAttachment) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateTime(value?: ConfigType | null) {
|
||||
const target = value ? dayjs(value) : dayjs();
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function normalizeDate(value?: ConfigType | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = dayjs(value);
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : null;
|
||||
}
|
||||
|
||||
function createSeedItems(): PersonalItemRecord[] {
|
||||
const now = dayjs();
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'personal-item-1',
|
||||
taskTitle: '整理供应商沟通纪要',
|
||||
type: 'daily',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'active',
|
||||
progressRate: 45,
|
||||
plannedStartDate: normalizeDate(now.subtract(3, 'day')),
|
||||
plannedEndDate: normalizeDate(now.add(2, 'day')),
|
||||
actualStartDate: normalizeDate(now.subtract(2, 'day')),
|
||||
actualEndDate: null,
|
||||
taskDesc: '<p>补齐今天会议纪要,沉淀成一页内部记录,便于后续同步。</p>',
|
||||
lastStatusReason: null,
|
||||
attachments: null,
|
||||
creator: CURRENT_USER_NAME,
|
||||
createTime: normalizeDateTime(now.subtract(3, 'day').hour(9).minute(20).second(0)),
|
||||
updater: CURRENT_USER_NAME,
|
||||
updateTime: normalizeDateTime(now.subtract(2, 'hour')),
|
||||
deleted: false,
|
||||
ownerName: CURRENT_USER_NAME,
|
||||
statusName: '进行中'
|
||||
},
|
||||
{
|
||||
id: 'personal-item-2',
|
||||
taskTitle: '清理浏览器收藏夹里的项目入口',
|
||||
type: 'daily',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'pending',
|
||||
progressRate: 0,
|
||||
plannedStartDate: normalizeDate(now.add(1, 'day')),
|
||||
plannedEndDate: normalizeDate(now.add(4, 'day')),
|
||||
actualStartDate: null,
|
||||
actualEndDate: null,
|
||||
taskDesc: '<p>把已经废弃的测试环境、旧文档入口统一清理。</p>',
|
||||
lastStatusReason: null,
|
||||
attachments: null,
|
||||
creator: CURRENT_USER_NAME,
|
||||
createTime: normalizeDateTime(now.subtract(2, 'day').hour(14).minute(10).second(0)),
|
||||
updater: CURRENT_USER_NAME,
|
||||
updateTime: normalizeDateTime(now.subtract(5, 'hour')),
|
||||
deleted: false,
|
||||
ownerName: CURRENT_USER_NAME,
|
||||
statusName: '待处理'
|
||||
},
|
||||
{
|
||||
id: 'personal-item-3',
|
||||
taskTitle: '补充账号开通说明截图',
|
||||
type: 'support',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'completed',
|
||||
progressRate: 100,
|
||||
plannedStartDate: normalizeDate(now.subtract(5, 'day')),
|
||||
plannedEndDate: normalizeDate(now.subtract(2, 'day')),
|
||||
actualStartDate: normalizeDate(now.subtract(5, 'day')),
|
||||
actualEndDate: normalizeDate(now.subtract(1, 'day')),
|
||||
taskDesc: '<p>为新同事入职说明补一版截图,后续发在群公告。</p>',
|
||||
lastStatusReason: '已完成并同步团队',
|
||||
attachments: null,
|
||||
creator: CURRENT_USER_NAME,
|
||||
createTime: normalizeDateTime(now.subtract(5, 'day').hour(11).minute(0).second(0)),
|
||||
updater: CURRENT_USER_NAME,
|
||||
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(30).second(0)),
|
||||
deleted: false,
|
||||
ownerName: CURRENT_USER_NAME,
|
||||
statusName: '已完成'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createSeedWorklogs(): PersonalItemWorklogRecord[] {
|
||||
const now = dayjs();
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'worklog-1',
|
||||
taskId: 'personal-item-1',
|
||||
userId: CURRENT_USER_ID,
|
||||
userNickname: CURRENT_USER_NAME,
|
||||
startDate: normalizeDate(now.subtract(2, 'day'))!,
|
||||
endDate: normalizeDate(now.subtract(2, 'day'))!,
|
||||
durationHours: 2.5,
|
||||
progressRate: 30,
|
||||
difficulty: '2',
|
||||
workContent: '整理会议录音和重点结论,先输出初版纪要。',
|
||||
attachments: null,
|
||||
createTime: normalizeDateTime(now.subtract(2, 'day').hour(19)),
|
||||
updateTime: normalizeDateTime(now.subtract(2, 'day').hour(19))
|
||||
},
|
||||
{
|
||||
id: 'worklog-2',
|
||||
taskId: 'personal-item-1',
|
||||
userId: CURRENT_USER_ID,
|
||||
userNickname: CURRENT_USER_NAME,
|
||||
startDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||
endDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||
durationHours: 1.5,
|
||||
progressRate: 45,
|
||||
difficulty: '2',
|
||||
workContent: '补全供应商待确认项并整理后续跟进人。',
|
||||
attachments: null,
|
||||
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18)),
|
||||
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18))
|
||||
},
|
||||
{
|
||||
id: 'worklog-3',
|
||||
taskId: 'personal-item-3',
|
||||
userId: CURRENT_USER_ID,
|
||||
userNickname: CURRENT_USER_NAME,
|
||||
startDate: normalizeDate(now.subtract(5, 'day'))!,
|
||||
endDate: normalizeDate(now.subtract(5, 'day'))!,
|
||||
durationHours: 1,
|
||||
progressRate: 60,
|
||||
difficulty: '1',
|
||||
workContent: '补拍账号开通流程截图。',
|
||||
attachments: null,
|
||||
createTime: normalizeDateTime(now.subtract(5, 'day').hour(15)),
|
||||
updateTime: normalizeDateTime(now.subtract(5, 'day').hour(15))
|
||||
},
|
||||
{
|
||||
id: 'worklog-4',
|
||||
taskId: 'personal-item-3',
|
||||
userId: CURRENT_USER_ID,
|
||||
userNickname: CURRENT_USER_NAME,
|
||||
startDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||
endDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||
durationHours: 0.5,
|
||||
progressRate: 100,
|
||||
difficulty: '1',
|
||||
workContent: '校对文案并发到群公告。',
|
||||
attachments: null,
|
||||
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20)),
|
||||
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20))
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createExecutionOptions(): Api.PersonalItem.PersonalItemExecutionOption[] {
|
||||
return [
|
||||
{
|
||||
executionId: 'execution-1001',
|
||||
executionName: '2026Q2 运营提效',
|
||||
projectId: 'project-1001',
|
||||
projectName: '运营中台优化'
|
||||
},
|
||||
{
|
||||
executionId: 'execution-1002',
|
||||
executionName: '2026Q2 用户支持专项',
|
||||
projectId: 'project-1002',
|
||||
projectName: '基础平台升级'
|
||||
},
|
||||
{
|
||||
executionId: 'execution-1003',
|
||||
executionName: '2026Q3 数据治理',
|
||||
projectId: 'project-1003',
|
||||
projectName: '数据资产规范化'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function findItemIndex(id: string) {
|
||||
return personalItems.findIndex(item => item.id === id);
|
||||
}
|
||||
|
||||
function getItemOrThrow(id: string) {
|
||||
const item = personalItems.find(current => current.id === id && !current.deleted);
|
||||
|
||||
if (!item) {
|
||||
throw new Error(`personal item not found: ${id}`);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function sortItems(list: PersonalItemRecord[]) {
|
||||
return [...list].sort((left, right) => dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf());
|
||||
}
|
||||
|
||||
function sortWorklogs(list: PersonalItemWorklogRecord[]) {
|
||||
return [...list].sort((left, right) => {
|
||||
const endDiff = dayjs(right.endDate).valueOf() - dayjs(left.endDate).valueOf();
|
||||
if (endDiff !== 0) {
|
||||
return endDiff;
|
||||
}
|
||||
return dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf();
|
||||
});
|
||||
}
|
||||
|
||||
function getPersonalItemStatusName(statusCode: Api.PersonalItem.PersonalItemStatusCode) {
|
||||
const statusNameMap: Partial<Record<Api.PersonalItem.PersonalItemStatusCode, string>> = {
|
||||
pending: '待处理',
|
||||
active: '进行中',
|
||||
completed: '已完成'
|
||||
};
|
||||
|
||||
return statusNameMap[statusCode] || statusCode;
|
||||
}
|
||||
|
||||
function removeItemsByIds(ids: string[]) {
|
||||
const idSet = new Set(ids);
|
||||
|
||||
for (let i = personalItems.length - 1; i >= 0; i -= 1) {
|
||||
if (idSet.has(personalItems[i].id)) {
|
||||
personalItems.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = personalItemWorklogs.length - 1; i >= 0; i -= 1) {
|
||||
if (idSet.has(personalItemWorklogs[i].taskId)) {
|
||||
personalItemWorklogs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sumWorklogHours(logs: PersonalItemWorklogRecord[]) {
|
||||
return logs.reduce((sum, log) => sum + (log.durationHours ?? 0), 0);
|
||||
}
|
||||
|
||||
function syncItemFromWorklogs(itemId: string) {
|
||||
const item = getItemOrThrow(itemId);
|
||||
const logs = sortWorklogs(personalItemWorklogs.filter(log => log.taskId === itemId));
|
||||
|
||||
item.statusName = getPersonalItemStatusName(item.statusCode);
|
||||
item.totalSpentHours = sumWorklogHours(logs);
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (item.statusCode !== 'completed') {
|
||||
item.progressRate = 0;
|
||||
item.actualStartDate = null;
|
||||
item.actualEndDate = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const latestLog = logs[0];
|
||||
const chronologicalLogs = [...logs].sort(
|
||||
(left, right) => dayjs(left.startDate).valueOf() - dayjs(right.startDate).valueOf()
|
||||
);
|
||||
|
||||
item.progressRate = latestLog.progressRate ?? item.progressRate;
|
||||
item.actualStartDate = chronologicalLogs[0]?.startDate ?? item.actualStartDate;
|
||||
item.actualEndDate = latestLog.endDate ?? item.actualEndDate;
|
||||
item.updateTime = latestLog.updateTime;
|
||||
item.updater = CURRENT_USER_NAME;
|
||||
|
||||
if (item.statusCode === 'pending') {
|
||||
item.statusCode = 'active';
|
||||
item.statusName = getPersonalItemStatusName(item.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
function applySaveFields(target: PersonalItemRecord, payload: Api.PersonalItem.SavePersonalItemParams) {
|
||||
target.taskTitle = payload.taskTitle.trim();
|
||||
target.type = payload.type;
|
||||
target.ownerId = payload.ownerId || target.ownerId;
|
||||
target.ownerName = CURRENT_USER_NAME;
|
||||
target.plannedStartDate = payload.plannedStartDate;
|
||||
target.plannedEndDate = payload.plannedEndDate;
|
||||
target.taskDesc = payload.taskDesc ?? null;
|
||||
target.attachments = payload.attachments?.map(cloneAttachment) ?? null;
|
||||
target.updater = CURRENT_USER_NAME;
|
||||
target.updateTime = normalizeDateTime();
|
||||
}
|
||||
|
||||
function filterWorklogs(taskId: string, params?: Api.PersonalItem.PersonalItemWorklogSearchParams) {
|
||||
return sortWorklogs(
|
||||
personalItemWorklogs.filter(item => {
|
||||
if (item.taskId !== taskId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params?.userId && item.userId !== params.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params?.startDate && dayjs(item.endDate).isBefore(dayjs(params.startDate), 'day')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params?.endDate && dayjs(item.startDate).isAfter(dayjs(params.endDate), 'day')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGetPersonalItemPage(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
|
||||
const query = createPersonalItemPageQuery(params);
|
||||
|
||||
const result = await request<PersonalItemPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${PERSONAL_ITEM_PREFIX}/page?${query}` : `${PERSONAL_ITEM_PREFIX}/page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<PersonalItemPageResponse>, data => ({
|
||||
total: normalizePageTotal(data.total),
|
||||
list: data.list.map(normalizePersonalItem)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGetPersonalItemDetail(id: string) {
|
||||
const result = await request<PersonalItemResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<PersonalItemResponse>, normalizePersonalItem);
|
||||
}
|
||||
|
||||
export async function fetchCreatePersonalItem(data: Api.PersonalItem.SavePersonalItemParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: PERSONAL_ITEM_PREFIX,
|
||||
method: 'post',
|
||||
data: toPersonalItemSaveRequest(data)
|
||||
});
|
||||
|
||||
const mapped = mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
|
||||
if (!mapped.error && mapped.data) {
|
||||
const now = normalizeDateTime();
|
||||
const createdItem: PersonalItemRecord = {
|
||||
id: mapped.data,
|
||||
taskTitle: data.taskTitle.trim(),
|
||||
type: data.type,
|
||||
ownerId: data.ownerId || CURRENT_USER_ID,
|
||||
statusCode: 'pending',
|
||||
progressRate: typeof data.progressRate === 'number' ? data.progressRate : 0,
|
||||
plannedStartDate: data.plannedStartDate,
|
||||
plannedEndDate: data.plannedEndDate,
|
||||
actualStartDate: null,
|
||||
actualEndDate: null,
|
||||
taskDesc: data.taskDesc ?? null,
|
||||
lastStatusReason: null,
|
||||
attachments: data.attachments?.map(cloneAttachment) ?? null,
|
||||
creator: CURRENT_USER_NAME,
|
||||
createTime: now,
|
||||
updater: CURRENT_USER_NAME,
|
||||
updateTime: now,
|
||||
deleted: false,
|
||||
ownerName: CURRENT_USER_NAME,
|
||||
statusName: getPersonalItemStatusName('pending')
|
||||
};
|
||||
|
||||
personalItems.unshift(createdItem);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function fetchUpdatePersonalItem(data: Api.PersonalItem.UpdatePersonalItemParams) {
|
||||
const result = await request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${data.id}`,
|
||||
method: 'put',
|
||||
data: toPersonalItemSaveRequest(data)
|
||||
});
|
||||
|
||||
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||
|
||||
if (!mapped.error && mapped.data) {
|
||||
const targetIndex = findItemIndex(data.id);
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
applySaveFields(personalItems[targetIndex], data);
|
||||
}
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function fetchChangePersonalItemStatus(id: string, data: Api.PersonalItem.ChangePersonalItemStatusParams) {
|
||||
const result = await request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${id}/change-status`,
|
||||
method: 'post',
|
||||
data: {
|
||||
actionCode: data.actionCode,
|
||||
reason: data.reason ?? undefined
|
||||
}
|
||||
});
|
||||
|
||||
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||
|
||||
if (!mapped.error && mapped.data) {
|
||||
const target = personalItems.find(item => item.id === id);
|
||||
|
||||
if (target) {
|
||||
target.lastStatusReason = data.reason ?? null;
|
||||
target.updater = CURRENT_USER_NAME;
|
||||
target.updateTime = normalizeDateTime();
|
||||
|
||||
if (data.actionCode === 'start') {
|
||||
target.statusCode = 'active';
|
||||
target.statusName = getPersonalItemStatusName('active');
|
||||
target.actualStartDate ??= normalizeDate(dayjs());
|
||||
target.actualEndDate = null;
|
||||
} else if (data.actionCode === 'complete') {
|
||||
target.statusCode = 'completed';
|
||||
target.statusName = getPersonalItemStatusName('completed');
|
||||
target.progressRate = 100;
|
||||
target.actualStartDate ??= normalizeDate(dayjs());
|
||||
target.actualEndDate = normalizeDate(dayjs());
|
||||
} else if (data.actionCode === 'reopen') {
|
||||
target.statusCode = 'active';
|
||||
target.statusName = getPersonalItemStatusName('active');
|
||||
target.actualStartDate ??= normalizeDate(dayjs());
|
||||
target.actualEndDate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function fetchDeletePersonalItem(id: string) {
|
||||
const result = await request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||
|
||||
if (!mapped.error && mapped.data) {
|
||||
removeItemsByIds([id]);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function fetchBatchDeletePersonalItems(payload: Api.PersonalItem.BatchDeletePersonalItemParams) {
|
||||
const query = createIdsQuery(payload.ids);
|
||||
const result = await request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${PERSONAL_ITEM_PREFIX}/delete-list?${query}` : `${PERSONAL_ITEM_PREFIX}/delete-list`,
|
||||
method: 'delete'
|
||||
});
|
||||
|
||||
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||
|
||||
if (!mapped.error && mapped.data) {
|
||||
removeItemsByIds(payload.ids);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function fetchGetPersonalItemExecutionOptions() {
|
||||
const result = await request<PersonalItemExecutionOptionResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/owner/all-execution`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<PersonalItemExecutionOptionResponse[]>, data =>
|
||||
data.map(normalizePersonalItemExecutionOption)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchBindPersonalItemsToExecution(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
|
||||
const query = createBindExecutionQuery(payload);
|
||||
const result = await request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${PERSONAL_ITEM_PREFIX}/relate-execution?${query}` : `${PERSONAL_ITEM_PREFIX}/relate-execution`,
|
||||
method: 'post'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||
}
|
||||
|
||||
export function fetchStartPersonalItem(id: string): PersonalItemResult<boolean> {
|
||||
return fetchChangePersonalItemStatus(id, { actionCode: 'start' }) as PersonalItemResult<boolean>;
|
||||
}
|
||||
|
||||
export function fetchCompletePersonalItem(id: string): PersonalItemResult<boolean> {
|
||||
return fetchChangePersonalItemStatus(id, { actionCode: 'complete' }) as PersonalItemResult<boolean>;
|
||||
}
|
||||
|
||||
export function fetchReopenPersonalItem(id: string): PersonalItemResult<boolean> {
|
||||
return fetchChangePersonalItemStatus(id, { actionCode: 'reopen' }) as PersonalItemResult<boolean>;
|
||||
}
|
||||
|
||||
export async function fetchGetPersonalItemWorklogPage(
|
||||
taskId: string,
|
||||
params: Api.PersonalItem.PersonalItemWorklogSearchParams = {}
|
||||
) {
|
||||
const result = await request<PersonalItemWorklogPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<PersonalItemWorklogPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeTaskWorklog)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchCreatePersonalItemWorklog(
|
||||
taskId: string,
|
||||
data: Api.PersonalItem.SavePersonalItemWorklogParams
|
||||
) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
|
||||
method: 'post',
|
||||
data: toPersonalItemWorklogSaveRequest(data)
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function fetchUpdatePersonalItemWorklog(
|
||||
taskId: string,
|
||||
payload: { worklogId: string; data: Api.PersonalItem.SavePersonalItemWorklogParams }
|
||||
): PersonalItemResult<boolean> {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${payload.worklogId}`,
|
||||
method: 'put',
|
||||
data: toPersonalItemWorklogSaveRequest(payload.data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeletePersonalItemWorklog(taskId: string, worklogId: string): PersonalItemResult<boolean> {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${worklogId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
83
src/service/api/product-shared.ts
Normal file
83
src/service/api/product-shared.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||
|
||||
type ProductStatusCode = Api.Product.ProductStatusCode;
|
||||
type ProductStatusActionCode = Api.Product.ProductStatusActionCode;
|
||||
|
||||
interface ProductSettingsResponse {
|
||||
baseInfo: {
|
||||
id: string | number;
|
||||
code: string;
|
||||
directionCode: string;
|
||||
name: string;
|
||||
managerUserId?: string | number | null;
|
||||
managerUserNickname?: string | null;
|
||||
description?: string | null;
|
||||
statusCode: ProductStatusCode;
|
||||
lastStatusReason?: string | null;
|
||||
};
|
||||
lifecycle: {
|
||||
statusCode: ProductStatusCode;
|
||||
lastStatusReason?: string | null;
|
||||
availableActions?: Array<{
|
||||
actionCode: ProductStatusActionCode;
|
||||
actionName: string;
|
||||
needReason: boolean;
|
||||
}> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductMemberResponse {
|
||||
id: string | number;
|
||||
userId: string | number;
|
||||
userNickname: string;
|
||||
roleId: string | number;
|
||||
roleName: string;
|
||||
roleCode: string;
|
||||
managerFlag: boolean;
|
||||
status: 0 | 1;
|
||||
joinedTime: string;
|
||||
leftTime?: string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export function normalizeProductSettings(response: ProductSettingsResponse): Api.Product.ProductSettings {
|
||||
return {
|
||||
baseInfo: {
|
||||
id: normalizeStringId(response.baseInfo.id),
|
||||
code: response.baseInfo.code || '',
|
||||
directionCode: response.baseInfo.directionCode || '',
|
||||
name: response.baseInfo.name || '',
|
||||
managerUserId: normalizeNullableStringId(response.baseInfo.managerUserId) ?? '',
|
||||
managerUserNickname: response.baseInfo.managerUserNickname || '',
|
||||
description: response.baseInfo.description ?? null,
|
||||
statusCode: response.baseInfo.statusCode,
|
||||
lastStatusReason: response.baseInfo.lastStatusReason ?? null
|
||||
},
|
||||
lifecycle: {
|
||||
statusCode: response.lifecycle.statusCode,
|
||||
lastStatusReason: response.lifecycle.lastStatusReason ?? null,
|
||||
availableActions:
|
||||
response.lifecycle.availableActions?.map(item => ({
|
||||
actionCode: item.actionCode,
|
||||
actionName: item.actionName,
|
||||
needReason: item.needReason
|
||||
})) ?? []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProductMember(response: ProductMemberResponse): Api.Product.ProductMember {
|
||||
return {
|
||||
id: normalizeStringId(response.id),
|
||||
userId: normalizeStringId(response.userId),
|
||||
userNickname: response.userNickname || '',
|
||||
roleId: normalizeStringId(response.roleId),
|
||||
roleName: response.roleName || '',
|
||||
roleCode: response.roleCode || '',
|
||||
managerFlag: Boolean(response.managerFlag),
|
||||
status: response.status,
|
||||
joinedTime: response.joinedTime,
|
||||
leftTime: response.leftTime ?? null,
|
||||
remark: response.remark ?? null
|
||||
};
|
||||
}
|
||||
704
src/service/api/product.ts
Normal file
704
src/service/api/product.ts
Normal file
@@ -0,0 +1,704 @@
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
import { normalizeProductMember, normalizeProductSettings } from './product-shared';
|
||||
|
||||
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
|
||||
|
||||
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
|
||||
id: string | number;
|
||||
managerUserId?: string | number | null;
|
||||
};
|
||||
|
||||
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
||||
|
||||
type ProductActivityTimelineItemResponse = Omit<
|
||||
Api.Product.ProductActivityTimelineItem,
|
||||
'id' | 'operatorUserId' | 'targetUserId' | 'occurredAt'
|
||||
> & {
|
||||
id: string | number;
|
||||
operatorUserId?: string | number | null;
|
||||
targetUserId?: string | number | null;
|
||||
occurredAt: number | string;
|
||||
};
|
||||
|
||||
type ProductActivityTimelinePageResponse = Omit<
|
||||
Api.Product.PageResult<ProductActivityTimelineItemResponse>,
|
||||
'total'
|
||||
> & {
|
||||
total: number | string;
|
||||
};
|
||||
|
||||
function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
return {
|
||||
...product,
|
||||
id: normalizeStringId(product.id),
|
||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOccurredAt(occurredAt: number | string) {
|
||||
const value = Number(occurredAt);
|
||||
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizePageTotal(total: number | string) {
|
||||
const value = Number(total);
|
||||
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function normalizeProductActivityTimelineItem(
|
||||
item: ProductActivityTimelineItemResponse
|
||||
): Api.Product.ProductActivityTimelineItem {
|
||||
return {
|
||||
...item,
|
||||
id: normalizeStringId(item.id),
|
||||
operatorUserId: normalizeNullableStringId(item.operatorUserId),
|
||||
targetUserId: normalizeNullableStringId(item.targetUserId),
|
||||
occurredAt: normalizeOccurredAt(item.occurredAt)
|
||||
};
|
||||
}
|
||||
|
||||
function createProductActivityTimelinePageQuery(params: Api.Product.ProductActivityTimelinePageParams) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo));
|
||||
query.append('pageSize', String(params.pageSize));
|
||||
|
||||
if (params.activityType) {
|
||||
query.append('activityType', params.activityType);
|
||||
}
|
||||
|
||||
params.actionTypes?.forEach(actionType => {
|
||||
if (actionType) {
|
||||
query.append('actionTypes', actionType);
|
||||
}
|
||||
});
|
||||
|
||||
if (params.startTime && params.endTime) {
|
||||
query.append('startTime', params.startTime);
|
||||
query.append('endTime', params.endTime);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
/** 获取产品分页 */
|
||||
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
|
||||
const result = await request<ProductPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProductPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeProduct)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 获取产品入口页概览统计 */
|
||||
export function fetchGetProductOverviewSummary() {
|
||||
return request<Api.Product.ProductOverviewSummary>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/overview-summary`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取产品详情 */
|
||||
export async function fetchGetProduct(id: string) {
|
||||
const result = await request<ProductResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
|
||||
}
|
||||
|
||||
/** 新增产品 */
|
||||
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 创建产品(含初始团队,原子接口) */
|
||||
export async function fetchCreateProductWithTeam(data: Api.Product.CreateProductWithTeamParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/create-with-team`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新产品 */
|
||||
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 改变产品状态 */
|
||||
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/change-status`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/delete`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 产品需求 API ==========
|
||||
const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
|
||||
|
||||
type RequirementResponse = Omit<
|
||||
Api.Product.Requirement,
|
||||
| 'id'
|
||||
| 'parentId'
|
||||
| 'moduleId'
|
||||
| 'proposerId'
|
||||
| 'currentHandlerUserId'
|
||||
| 'implementProjectId'
|
||||
| 'sourceBizId'
|
||||
| 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
moduleId: string | number;
|
||||
proposerId: string | number;
|
||||
currentHandlerUserId?: string | number | null;
|
||||
implementProjectId?: string | number | null;
|
||||
implementProjectName?: string | null;
|
||||
sourceBizId?: string | number | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
children?: RequirementResponse[];
|
||||
};
|
||||
|
||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||
type RequirementReviewResponse = Omit<
|
||||
Api.Product.RequirementReview,
|
||||
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId: string | number;
|
||||
operatorId: string | number;
|
||||
attendees?: Array<{
|
||||
userId: string | number;
|
||||
nickname: string;
|
||||
}>;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
};
|
||||
type ProductRequirementDashboardSummaryResponse = {
|
||||
total?: number | string | null;
|
||||
todo?: number | string | null;
|
||||
pendingClaim?: number | string | null;
|
||||
pendingReview?: number | string | null;
|
||||
pendingDispatch?: number | string | null;
|
||||
completed?: number | string | null;
|
||||
completionRate?: number | string | null;
|
||||
highPriorityTodo?: number | string | null;
|
||||
};
|
||||
type ProductRequirementDashboardRecentChangeResponse = Omit<
|
||||
Api.Product.ProductRequirementDashboardRecentChange,
|
||||
'id' | 'requirementId' | 'operatorUserId'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId?: string | number | null;
|
||||
operatorUserId?: string | number | null;
|
||||
};
|
||||
type ProductRequirementDashboardResponse = {
|
||||
summary?: ProductRequirementDashboardSummaryResponse | null;
|
||||
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
|
||||
};
|
||||
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: string | number;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list.map(item => {
|
||||
const rawId = item.fileId ?? item.id;
|
||||
|
||||
return {
|
||||
...item,
|
||||
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
|
||||
return {
|
||||
...requirement,
|
||||
id: normalizeStringId(requirement.id),
|
||||
parentId: normalizeStringId(requirement.parentId),
|
||||
moduleId: normalizeStringId(requirement.moduleId),
|
||||
proposerId: normalizeStringId(requirement.proposerId),
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
||||
implementProjectName: requirement.implementProjectName ?? null,
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
children: requirement.children?.map(normalizeRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview {
|
||||
return {
|
||||
...review,
|
||||
id: normalizeStringId(review.id),
|
||||
requirementId: normalizeStringId(review.requirementId),
|
||||
operatorId: normalizeStringId(review.operatorId),
|
||||
attendees: review.attendees?.map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId)
|
||||
})),
|
||||
attachments: normalizeAttachments(review.attachments)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDashboardCount(value: number | string | null | undefined) {
|
||||
const count = Number(value ?? 0);
|
||||
|
||||
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
||||
}
|
||||
|
||||
function normalizeProductRequirementDashboard(
|
||||
data: ProductRequirementDashboardResponse
|
||||
): Api.Product.ProductRequirementDashboard {
|
||||
const summary = data.summary ?? {};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: normalizeDashboardCount(summary.total),
|
||||
todo: normalizeDashboardCount(summary.todo),
|
||||
pendingClaim: normalizeDashboardCount(summary.pendingClaim),
|
||||
pendingReview: normalizeDashboardCount(summary.pendingReview),
|
||||
pendingDispatch: normalizeDashboardCount(summary.pendingDispatch),
|
||||
completed: normalizeDashboardCount(summary.completed),
|
||||
completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)),
|
||||
highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo)
|
||||
},
|
||||
recentChanges: (data.recentChanges ?? []).map(item => ({
|
||||
...item,
|
||||
id: normalizeStringId(item.id),
|
||||
requirementId: normalizeNullableStringId(item.requirementId),
|
||||
operatorUserId: normalizeNullableStringId(item.operatorUserId)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取需求分页列表 */
|
||||
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
||||
const result = await request<RequirementPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeRequirement)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 获取需求树形列表(支持分页,pageSize只算父需求) */
|
||||
export async function fetchGetRequirementTree(params?: Api.Product.RequirementSearchParams) {
|
||||
const result = await request<Api.Product.PageResult<RequirementResponse>>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/tree`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.PageResult<RequirementResponse>>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeRequirement)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 获取需求详情 */
|
||||
export async function fetchGetRequirement(id: string, productId: string) {
|
||||
const result = await request<RequirementResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id, productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementResponse>, normalizeRequirement);
|
||||
}
|
||||
|
||||
/** 创建需求 */
|
||||
export async function fetchCreateRequirement(data: Api.Product.SaveRequirementParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新需求 */
|
||||
export function fetchUpdateRequirement(data: Api.Product.UpdateRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 变更需求状态 */
|
||||
export function fetchChangeRequirementStatus(data: Api.Product.ChangeRequirementStatusParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/change-status`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除需求 */
|
||||
export function fetchDeleteRequirement(data: Api.Product.DeleteRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/delete`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 拆分需求 */
|
||||
export async function fetchSplitRequirement(data: Api.Product.SplitRequirementParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/split`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
/** 获取需求可执行的状态动作列表 */
|
||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/allowed-transitions`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
|
||||
}
|
||||
|
||||
/** 批量获取需求可执行的状态动作列表 */
|
||||
export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||
const result = await request<Api.Product.RequirementAllowedTransitionBatchRespVO[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/allowed-transitions/batch`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<Api.Product.RequirementAllowedTransitionBatchRespVO[]>,
|
||||
data1 =>
|
||||
data1.map(item => ({
|
||||
requirementId: normalizeStringId(item.requirementId),
|
||||
transitions: item.transitions
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** 提交产品需求评审 */
|
||||
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 获取产品需求评审记录 */
|
||||
export async function fetchGetProductRequirementReview(productId: string, requirementId: string) {
|
||||
const result = await request<RequirementReviewResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/review/get`,
|
||||
method: 'get',
|
||||
params: { productId, requirementId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementReviewResponse>, normalizeRequirementReview);
|
||||
}
|
||||
|
||||
/** 获取产品概览需求池实时看板 */
|
||||
export async function fetchGetProductRequirementDashboard(productId: string) {
|
||||
const result = await request<ProductRequirementDashboardResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/dashboard`,
|
||||
method: 'get',
|
||||
params: { productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProductRequirementDashboardResponse>,
|
||||
normalizeProductRequirementDashboard
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求所有状态字典 */
|
||||
export async function fetchGetRequirementStatusDict() {
|
||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/status/dict`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/has-dispatched`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/has-dispatched/batch`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementHasDispatchedBatchRespVO[]>, data1 =>
|
||||
data1.map(item => ({
|
||||
requirementId: normalizeStringId(item.requirementId),
|
||||
hasDispatched: Boolean(item.hasDispatched)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据当前产品需求id获取对应地,所流转到项目侧的项目需求id */
|
||||
export async function fetchGetDispatchedProjectLink(productRequirementId: string) {
|
||||
return request<{ projectRequirementId: string; projectId: string }>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/dispatched-project-link`,
|
||||
method: 'get',
|
||||
params: { productRequirementId }
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 模块管理 API ==========
|
||||
type RequirementModuleResponse = Omit<Api.Product.RequirementModule, 'id' | 'parentId' | 'productId'> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
productId: string | number;
|
||||
children?: RequirementModuleResponse[];
|
||||
};
|
||||
|
||||
function normalizeRequirementModule(module: RequirementModuleResponse): Api.Product.RequirementModule {
|
||||
return {
|
||||
...module,
|
||||
id: normalizeStringId(module.id),
|
||||
parentId: normalizeStringId(module.parentId),
|
||||
productId: normalizeStringId(module.productId),
|
||||
children: module.children?.map(normalizeRequirementModule)
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取需求模块树 */
|
||||
export async function fetchGetRequirementModuleTree(productId: string) {
|
||||
const result = await request<RequirementModuleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/module/tree`,
|
||||
method: 'get',
|
||||
params: { productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementModuleResponse[]>, data =>
|
||||
data.map(normalizeRequirementModule)
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建需求模块 */
|
||||
export async function fetchCreateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/module/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新需求模块 */
|
||||
export function fetchUpdateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/module/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除需求模块 */
|
||||
export function fetchDeleteRequirementModule(data: Api.Product.DeleteRequirementModuleParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/module/delete`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGetProductSettings(id: string) {
|
||||
const result = await request<Api.Product.ProductSettings>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/settings`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.ProductSettings>, normalizeProductSettings);
|
||||
}
|
||||
|
||||
export function fetchUpdateProductSettingBaseInfo(id: string, data: Api.Product.UpdateProductSettingBaseInfoParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/settings/base-info`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGetProductMembers(id: string) {
|
||||
const result = await request<Api.Product.ProductMember[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.ProductMember[]>, data =>
|
||||
data.map(normalizeProductMember)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGetProductActivityTimelinePage(
|
||||
id: string,
|
||||
params: Api.Product.ProductActivityTimelinePageParams
|
||||
) {
|
||||
const query = createProductActivityTimelinePageQuery(params);
|
||||
const url = query ? `${PRODUCT_PREFIX}/${id}/activities/page?${query}` : `${PRODUCT_PREFIX}/${id}/activities/page`;
|
||||
const result = await request<ProductActivityTimelinePageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProductActivityTimelinePageResponse>, data => ({
|
||||
total: normalizePageTotal(data.total),
|
||||
list: data.list.map(normalizeProductActivityTimelineItem)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchCreateProductMember(id: string, data: Api.Product.CreateProductMemberParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
export async function fetchBatchCreateProductMembers(id: string, data: Api.Product.BatchCreateProductMembersParams) {
|
||||
const result = await request<Array<string | number>>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members/batch`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
|
||||
Array.isArray(list) ? list.map(normalizeStringId) : []
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members/${memberId}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBatchInactiveProductMembers(id: string, data: Api.Product.BatchInactiveProductMembersParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members/batch/inactive`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchInactiveProductMember(
|
||||
id: string,
|
||||
memberId: string,
|
||||
data: Api.Product.InactiveProductMemberParams
|
||||
) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members/${memberId}/inactive`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
397
src/service/api/project-shared.ts
Normal file
397
src/service/api/project-shared.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||
|
||||
type ProjectStatusCode = Api.Project.ProjectStatusCode;
|
||||
type ProjectStatusActionCode = Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>;
|
||||
|
||||
type StringIdResponse = string | number;
|
||||
|
||||
export type ProjectLocalDateValue = string | number[] | null;
|
||||
|
||||
export type LifecycleActionResponse<ActionCode extends string> = Partial<Api.Project.LifecycleAction<ActionCode>> & {
|
||||
actionCode: ActionCode;
|
||||
};
|
||||
|
||||
export type ProjectExecutionResponse = Omit<
|
||||
Api.Project.ProjectExecution,
|
||||
| 'id'
|
||||
| 'projectId'
|
||||
| 'projectRequirementId'
|
||||
| 'ownerId'
|
||||
| 'availableActions'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'progressRate'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
projectRequirementId?: StringIdResponse | null;
|
||||
ownerId: StringIdResponse;
|
||||
availableActions?: LifecycleActionResponse<Api.Project.ProjectExecutionActionCode>[] | null;
|
||||
plannedStartDate?: ProjectLocalDateValue;
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
progressRate?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
||||
id: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
};
|
||||
|
||||
export type ExecutionAssigneeLogResponse = Omit<
|
||||
Api.Project.ExecutionAssigneeLog,
|
||||
'id' | 'executionId' | 'userId' | 'operatorUserId'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
};
|
||||
|
||||
type TaskAssigneeRefResponse = Omit<Api.Project.TaskAssigneeRef, 'id' | 'userId'> & {
|
||||
id: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* 后端 attachments 项的兼容形态:历史/当前响应字段名是 `id`,前端类型统一用 `fileId`。
|
||||
* normalizeAttachments 负责把两者归一成 `fileId`。
|
||||
*/
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: StringIdResponse;
|
||||
id?: StringIdResponse;
|
||||
};
|
||||
|
||||
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
return list.map(item => {
|
||||
const rawId = item.fileId ?? item.id;
|
||||
return {
|
||||
...item,
|
||||
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 5.6 单独接口返的协办人字段(与 5.3 嵌入字段命名口径不一致:返 userNickname 而非 nickname)。
|
||||
* 经 normalizeTaskAssignee 归一化后对外统一为 Api.Project.TaskAssigneeRef。
|
||||
*/
|
||||
export type TaskAssigneeFromApiResponse = {
|
||||
id: StringIdResponse;
|
||||
taskId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
userNickname?: string | null;
|
||||
joinedAt?: string | null;
|
||||
};
|
||||
|
||||
export type TaskAssigneeLogResponse = Omit<
|
||||
Api.Project.TaskAssigneeLog,
|
||||
'id' | 'taskId' | 'userId' | 'operatorUserId'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
taskId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
};
|
||||
|
||||
export type ProjectTaskResponse = Omit<
|
||||
Api.Project.ProjectTask,
|
||||
| 'id'
|
||||
| 'projectId'
|
||||
| 'executionId'
|
||||
| 'parentTaskId'
|
||||
| 'ownerId'
|
||||
| 'executionOwnerId'
|
||||
| 'parentTaskOwnerId'
|
||||
| 'availableActions'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'progressRate'
|
||||
| 'assignees'
|
||||
| 'attachments'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
executionName?: string | null;
|
||||
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
|
||||
parentTaskId?: StringIdResponse | null;
|
||||
ownerId: StringIdResponse;
|
||||
executionOwnerId?: StringIdResponse | null;
|
||||
parentTaskOwnerId?: StringIdResponse | null;
|
||||
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||
plannedStartDate?: ProjectLocalDateValue;
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
progressRate?: number | null;
|
||||
assignees?: TaskAssigneeRefResponse[] | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
totalSpentHours?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type TaskWorklogResponse = Omit<
|
||||
Api.Project.TaskWorklog,
|
||||
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
taskId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
difficulty?: string | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
startDate?: ProjectLocalDateValue;
|
||||
endDate?: ProjectLocalDateValue;
|
||||
};
|
||||
|
||||
export interface ProjectMemberResponse {
|
||||
id: string | number;
|
||||
userId: string | number;
|
||||
userNickname: string;
|
||||
roleId: string | number;
|
||||
roleName: string;
|
||||
roleCode: string;
|
||||
managerFlag: boolean;
|
||||
status: 0 | 1;
|
||||
joinedTime: string;
|
||||
leftTime?: string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
const projectLifecycleActionNameMap: Record<ProjectStatusActionCode, string> = {
|
||||
pause: '暂停项目',
|
||||
resume: '恢复项目',
|
||||
complete: '完成项目',
|
||||
cancel: '取消项目',
|
||||
reopen: '重新开启',
|
||||
archive: '归档项目'
|
||||
};
|
||||
|
||||
const projectLifecycleActionReasonRequiredMap: Record<ProjectStatusActionCode, boolean> = {
|
||||
pause: true,
|
||||
resume: false,
|
||||
complete: true,
|
||||
cancel: true,
|
||||
reopen: true,
|
||||
archive: false
|
||||
};
|
||||
|
||||
const projectLifecycleActionMap: Record<ProjectStatusCode, ProjectStatusActionCode[]> = {
|
||||
pending: ['cancel'],
|
||||
active: ['pause', 'complete', 'cancel'],
|
||||
paused: ['resume', 'cancel'],
|
||||
completed: ['reopen', 'archive'],
|
||||
cancelled: [],
|
||||
archived: []
|
||||
};
|
||||
|
||||
export function getProjectLifecycleActions(statusCode: ProjectStatusCode): Api.Project.ProjectLifecycleAction[] {
|
||||
return projectLifecycleActionMap[statusCode].map(actionCode => ({
|
||||
actionCode,
|
||||
actionName: projectLifecycleActionNameMap[actionCode],
|
||||
needReason: projectLifecycleActionReasonRequiredMap[actionCode]
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const [year, month, day] = value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [year, month, day].map(item => String(item).padStart(2, '0')).join('-');
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeLifecycleActions<ActionCode extends string>(
|
||||
actions: LifecycleActionResponse<ActionCode>[] | null | undefined
|
||||
): Api.Project.LifecycleAction<ActionCode>[] {
|
||||
return (actions ?? []).map(action => ({
|
||||
actionCode: action.actionCode,
|
||||
actionName: action.actionName ?? '',
|
||||
needReason: Boolean(action.needReason)
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeProjectMember(response: ProjectMemberResponse): Api.Project.ProjectMember {
|
||||
return {
|
||||
id: normalizeStringId(response.id),
|
||||
userId: normalizeStringId(response.userId),
|
||||
userNickname: response.userNickname || '',
|
||||
roleId: normalizeStringId(response.roleId),
|
||||
roleName: response.roleName || '',
|
||||
roleCode: response.roleCode || '',
|
||||
managerFlag: Boolean(response.managerFlag),
|
||||
status: response.status,
|
||||
joinedTime: response.joinedTime,
|
||||
leftTime: response.leftTime ?? null,
|
||||
remark: response.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePriority(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '1';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
statusName: response.statusName ?? null,
|
||||
terminal: Boolean(response.terminal),
|
||||
allowEdit: Boolean(response.allowEdit),
|
||||
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
executionDesc: response.executionDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
userId: normalizeStringId(response.userId),
|
||||
userNickname: response.userNickname ?? null,
|
||||
joinedAt: response.joinedAt ?? null,
|
||||
removedAt: response.removedAt ?? null,
|
||||
removedReason: response.removedReason ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExecutionAssigneeLog(
|
||||
response: ExecutionAssigneeLogResponse
|
||||
): Api.Project.ExecutionAssigneeLog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
userId: normalizeStringId(response.userId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
|
||||
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
|
||||
reason: response.reason ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project.ProjectTask {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
executionName: response.executionName ?? null,
|
||||
executionStatusCode: response.executionStatusCode ?? null,
|
||||
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
type: response.type ?? '',
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
|
||||
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
|
||||
statusName: response.statusName ?? null,
|
||||
terminal: Boolean(response.terminal),
|
||||
allowEdit: Boolean(response.allowEdit),
|
||||
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
taskDesc: response.taskDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null,
|
||||
assignees:
|
||||
response.assignees?.map(item => ({
|
||||
id: normalizeStringId(item.id),
|
||||
userId: normalizeStringId(item.userId),
|
||||
nickname: item.nickname ?? ''
|
||||
})) ?? null,
|
||||
attachments: normalizeAttachments(response.attachments),
|
||||
totalSpentHours: response.totalSpentHours ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project.TaskWorklog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
taskId: normalizeStringId(response.taskId),
|
||||
userId: normalizeStringId(response.userId),
|
||||
userNickname: response.userNickname ?? null,
|
||||
workContent: response.workContent ?? null,
|
||||
attachments: normalizeAttachments(response.attachments),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
// 后端 LocalDate 默认序列化为 [year, month, day] 数组,必须归一为 'YYYY-MM-DD' 字符串供 ElDatePicker 使用
|
||||
startDate: normalizeProjectLocalDate(response.startDate) ?? '',
|
||||
endDate: normalizeProjectLocalDate(response.endDate) ?? '',
|
||||
// 历史记录或异常缺失时兜底为字典默认档位 "2"
|
||||
difficulty: response.difficulty ?? '2',
|
||||
difficultyName: response.difficultyName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTaskAssignee(response: TaskAssigneeFromApiResponse): Api.Project.TaskAssigneeRef {
|
||||
return {
|
||||
id: normalizeStringId(response.id),
|
||||
userId: normalizeStringId(response.userId),
|
||||
nickname: response.userNickname ?? '',
|
||||
joinedAt: response.joinedAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTaskAssigneeLog(response: TaskAssigneeLogResponse): Api.Project.TaskAssigneeLog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
taskId: normalizeStringId(response.taskId),
|
||||
userId: normalizeStringId(response.userId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
|
||||
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
|
||||
reason: response.reason ?? null
|
||||
};
|
||||
}
|
||||
1218
src/service/api/project.ts
Normal file
1218
src/service/api/project.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import type { LastLevelRouteKey } from '@elegant-router/types';
|
||||
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { createStaticRoutes } from '@/router/routes';
|
||||
import { request } from '../request';
|
||||
import type { ServiceRequestResult } from './shared';
|
||||
import { type ServiceRequestResult, safeJsonRequestConfig } from './shared';
|
||||
|
||||
type BackendMenuRoute = Omit<Api.Route.MenuRoute, 'id' | 'children'> & {
|
||||
id: string | number;
|
||||
@@ -15,6 +17,12 @@ interface BackendUserRouteDTO {
|
||||
|
||||
let userRoutePromise: Promise<ServiceRequestResult<BackendUserRouteDTO>> | null = null;
|
||||
|
||||
const staticObjectContextRouteMap = new Map<App.ObjectContext.DomainKey, ElegantConstRoute>(
|
||||
createStaticRoutes()
|
||||
.authRoutes.filter(route => objectContextDomainConfigs.some(config => config.domainKey === route.name))
|
||||
.map(route => [route.name as App.ObjectContext.DomainKey, route])
|
||||
);
|
||||
|
||||
export function clearUserRouteCache() {
|
||||
userRoutePromise = null;
|
||||
}
|
||||
@@ -27,22 +35,164 @@ function normalizeMenuRoute(route: BackendMenuRoute): Api.Route.MenuRoute {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(path?: string | null) {
|
||||
if (!path) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
function isPathMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedPrefix = normalizePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
function isTopLevelObjectContextEntryRoute(route: Api.Route.MenuRoute, config: App.ObjectContext.DomainConfig) {
|
||||
const routePath = normalizePath(route.path);
|
||||
|
||||
return (
|
||||
route.component?.startsWith('view.') &&
|
||||
!route.children?.length &&
|
||||
(route.name === config.entryRouteKey || routePath === normalizePath(config.entryRoutePath))
|
||||
);
|
||||
}
|
||||
|
||||
function cloneStaticRouteAsMenuRoute(route: ElegantConstRoute, idPrefix: string): Api.Route.MenuRoute {
|
||||
return {
|
||||
...route,
|
||||
id: `${idPrefix}:${String(route.name || route.path)}`,
|
||||
children: route.children?.map(child => cloneStaticRouteAsMenuRoute(child, idPrefix))
|
||||
};
|
||||
}
|
||||
|
||||
function replaceWithStaticObjectContextDomainRoute(routes: Api.Route.MenuRoute[]) {
|
||||
let normalizedRoutes = [...routes];
|
||||
|
||||
objectContextDomainConfigs.forEach(config => {
|
||||
const hasDomainRootRoute = normalizedRoutes.some(route => route.name === config.domainKey);
|
||||
|
||||
if (hasDomainRootRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainTopLevelRoutes = normalizedRoutes.filter(route =>
|
||||
config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(route.path, prefix))
|
||||
);
|
||||
|
||||
const entryRoute = domainTopLevelRoutes.find(route => isTopLevelObjectContextEntryRoute(route, config));
|
||||
|
||||
if (!entryRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const staticDomainRoute = staticObjectContextRouteMap.get(config.domainKey);
|
||||
|
||||
if (!staticDomainRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of backend routes by name for quick lookup
|
||||
const backendRouteMap = new Map<string, Api.Route.MenuRoute>();
|
||||
domainTopLevelRoutes.forEach(route => {
|
||||
if (route.name) {
|
||||
backendRouteMap.set(String(route.name), route);
|
||||
}
|
||||
});
|
||||
|
||||
// Clone static route but preserve backend route's meta for children
|
||||
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
|
||||
// eslint-disable-next-line complexity
|
||||
function cloneStaticRoutePreservingBackendMeta(route: ElegantConstRoute, idPrefix: string): Api.Route.MenuRoute {
|
||||
const backendRoute = route.name ? backendRouteMap.get(String(route.name)) : undefined;
|
||||
const { children: _children, ...routeWithoutChildren } = route;
|
||||
const baseRoute: Api.Route.MenuRoute = {
|
||||
...routeWithoutChildren,
|
||||
id: `${idPrefix}:${String(route.name || route.path)}`
|
||||
};
|
||||
|
||||
// If there's a backend route, preserve its meta
|
||||
if (backendRoute?.meta) {
|
||||
baseRoute.meta = {
|
||||
...baseRoute.meta,
|
||||
title: backendRoute.meta.title || baseRoute.meta?.title || String(route.name || route.path),
|
||||
icon: backendRoute.meta.icon || baseRoute.meta?.icon,
|
||||
localIcon: backendRoute.meta.localIcon || baseRoute.meta?.localIcon,
|
||||
order:
|
||||
backendRoute.meta.order !== undefined && backendRoute.meta.order !== null
|
||||
? backendRoute.meta.order
|
||||
: baseRoute.meta?.order,
|
||||
keepAlive:
|
||||
backendRoute.meta.keepAlive !== undefined && backendRoute.meta.keepAlive !== null
|
||||
? backendRoute.meta.keepAlive
|
||||
: baseRoute.meta?.keepAlive,
|
||||
i18nKey: backendRoute.meta.i18nKey || baseRoute.meta?.i18nKey
|
||||
};
|
||||
}
|
||||
|
||||
// Recursively process children
|
||||
if (route.children?.length) {
|
||||
baseRoute.children = route.children.map(child => cloneStaticRoutePreservingBackendMeta(child, idPrefix));
|
||||
}
|
||||
|
||||
return baseRoute;
|
||||
}
|
||||
|
||||
const wrappedDomainRoute = cloneStaticRoutePreservingBackendMeta(
|
||||
staticDomainRoute,
|
||||
`object-context:${config.domainKey}`
|
||||
);
|
||||
|
||||
// Merge entry route's meta to domain route
|
||||
if (entryRoute.meta) {
|
||||
wrappedDomainRoute.meta = {
|
||||
...wrappedDomainRoute.meta,
|
||||
title: entryRoute.meta.title || wrappedDomainRoute.meta?.title || config.domainKey,
|
||||
icon: entryRoute.meta.icon || wrappedDomainRoute.meta?.icon,
|
||||
localIcon: entryRoute.meta.localIcon || wrappedDomainRoute.meta?.localIcon,
|
||||
order:
|
||||
entryRoute.meta.order !== undefined && entryRoute.meta.order !== null
|
||||
? entryRoute.meta.order
|
||||
: wrappedDomainRoute.meta?.order,
|
||||
keepAlive:
|
||||
entryRoute.meta.keepAlive !== undefined && entryRoute.meta.keepAlive !== null
|
||||
? entryRoute.meta.keepAlive
|
||||
: wrappedDomainRoute.meta?.keepAlive
|
||||
};
|
||||
}
|
||||
|
||||
const entryRouteIndex = normalizedRoutes.findIndex(route => route.id === entryRoute.id);
|
||||
const domainRouteIds = new Set(domainTopLevelRoutes.map(route => route.id));
|
||||
|
||||
normalizedRoutes = normalizedRoutes.filter(route => !domainRouteIds.has(route.id));
|
||||
normalizedRoutes.splice(entryRouteIndex < 0 ? normalizedRoutes.length : entryRouteIndex, 0, wrappedDomainRoute);
|
||||
});
|
||||
|
||||
return normalizedRoutes;
|
||||
}
|
||||
|
||||
function normalizeUserRoute(data: BackendUserRouteDTO): Api.Route.UserRoute {
|
||||
return {
|
||||
routes: (data.routes ?? []).map(route => normalizeMenuRoute(route)),
|
||||
routes: replaceWithStaticObjectContextDomainRoute((data.routes ?? []).map(route => normalizeMenuRoute(route))),
|
||||
home: (data.home || 'system_user') as LastLevelRouteKey
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取常量路由 */
|
||||
export function fetchGetConstantRoutes() {
|
||||
return request<Api.Route.MenuRoute[]>({ url: '/route/getConstantRoutes' });
|
||||
return request<Api.Route.MenuRoute[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: '/route/getConstantRoutes'
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取用户路由 */
|
||||
export async function fetchGetUserRoutes(force = false): Promise<ServiceRequestResult<Api.Route.UserRoute>> {
|
||||
if (!userRoutePromise || force) {
|
||||
userRoutePromise = request<BackendUserRouteDTO>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/auth/get-user-routes`
|
||||
}).then(result => result as ServiceRequestResult<BackendUserRouteDTO>);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { safeJsonTransformResponse } from '../request/json';
|
||||
|
||||
export type ServiceRequestResult<T> =
|
||||
| {
|
||||
@@ -11,3 +12,33 @@ export type ServiceRequestResult<T> =
|
||||
error: AxiosError<App.Service.Response<unknown>>;
|
||||
response: AxiosResponse<App.Service.Response<unknown>> | undefined;
|
||||
};
|
||||
|
||||
export const safeJsonRequestConfig: Pick<AxiosRequestConfig, 'transformResponse'> = {
|
||||
transformResponse: [safeJsonTransformResponse]
|
||||
};
|
||||
|
||||
export function normalizeStringId(id: string | number) {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
export function normalizeNullableStringId(id: string | number | null | undefined) {
|
||||
if (id === null || id === undefined || id === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(id);
|
||||
}
|
||||
|
||||
export function mapServiceResult<TInput, TOutput>(
|
||||
result: ServiceRequestResult<TInput>,
|
||||
mapper: (data: TInput) => TOutput
|
||||
): ServiceRequestResult<TOutput> {
|
||||
if (result.error || result.data === null) {
|
||||
return result as ServiceRequestResult<TOutput>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: mapper(result.data)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import type { ServiceRequestResult } from './shared';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
import UserManagementRelationQueryReqVO = Api.SystemManage.UserManagementRelationQueryReqVO;
|
||||
|
||||
const ROLE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/role`;
|
||||
const MENU_PREFIX = `${SYSTEM_SERVICE_PREFIX}/menu`;
|
||||
@@ -9,6 +16,7 @@ const USER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user`;
|
||||
const DEPT_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dept`;
|
||||
const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
|
||||
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
|
||||
const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`;
|
||||
|
||||
function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
|
||||
const query = new URLSearchParams();
|
||||
@@ -43,10 +51,18 @@ function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
|
||||
}
|
||||
});
|
||||
|
||||
if (params.scopeType) {
|
||||
query.append('scopeType', params.scopeType);
|
||||
}
|
||||
|
||||
if (params.objectType) {
|
||||
query.append('objectType', params.objectType);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function createBatchDeleteQuery(ids: number[]) {
|
||||
function createBatchDeleteQuery(ids: Array<string | number>) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
ids.forEach(id => {
|
||||
@@ -56,39 +72,162 @@ function createBatchDeleteQuery(ids: number[]) {
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
|
||||
id: string | number;
|
||||
deptId?: string | number | null;
|
||||
};
|
||||
|
||||
type RoleResponse = Omit<Api.SystemManage.Role, 'id'> & {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
type RolePageResponse = Api.SystemManage.PageResult<RoleResponse>;
|
||||
|
||||
type RoleSimpleResponse = Omit<Api.SystemManage.RoleSimple, 'id'> & {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
type MenuResponse = Omit<Api.SystemManage.Menu, 'id' | 'parentId' | 'children'> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
children?: MenuResponse[] | null;
|
||||
};
|
||||
|
||||
type MenuSimpleResponse = Omit<Api.SystemManage.MenuSimple, 'id' | 'parentId' | 'children'> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
children?: MenuSimpleResponse[] | null;
|
||||
};
|
||||
|
||||
type UserManagementRelationResponse = Omit<
|
||||
Api.SystemManage.UserManagementRelation,
|
||||
'id' | 'managerUserId' | 'subordinateUserId'
|
||||
> & {
|
||||
id: string | number | null;
|
||||
managerUserId: string | number | null;
|
||||
subordinateUserId: string | number | null;
|
||||
};
|
||||
|
||||
type UserManagementRelationTreeResponse = Omit<
|
||||
Api.SystemManage.UserManagementRelationTreeRespVO,
|
||||
'id' | 'userId' | 'managerUserId' | 'children'
|
||||
> & {
|
||||
id: string | number | null;
|
||||
userId: string | number;
|
||||
managerUserId: string | number | null;
|
||||
children?: UserManagementRelationTreeResponse[] | null;
|
||||
};
|
||||
|
||||
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
|
||||
return {
|
||||
...user,
|
||||
id: normalizeStringId(user.id),
|
||||
deptId: normalizeNullableStringId(user.deptId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRole(role: RoleResponse): Api.SystemManage.Role {
|
||||
return {
|
||||
...role,
|
||||
id: normalizeStringId(role.id)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoleSimple(role: RoleSimpleResponse): Api.SystemManage.RoleSimple {
|
||||
return {
|
||||
...role,
|
||||
id: normalizeStringId(role.id)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMenu(menu: MenuResponse): Api.SystemManage.Menu {
|
||||
return {
|
||||
...menu,
|
||||
id: normalizeStringId(menu.id),
|
||||
parentId: normalizeStringId(menu.parentId),
|
||||
children: menu.children?.map(normalizeMenu) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMenuSimple(menu: MenuSimpleResponse): Api.SystemManage.MenuSimple {
|
||||
return {
|
||||
...menu,
|
||||
id: normalizeStringId(menu.id),
|
||||
parentId: normalizeStringId(menu.parentId),
|
||||
children: menu.children?.map(normalizeMenuSimple) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUserManagementRelation(
|
||||
relation: UserManagementRelationResponse
|
||||
): Api.SystemManage.UserManagementRelation {
|
||||
return {
|
||||
...relation,
|
||||
id: normalizeNullableStringId(relation.id),
|
||||
managerUserId: normalizeNullableStringId(relation.managerUserId),
|
||||
subordinateUserId: normalizeNullableStringId(relation.subordinateUserId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUserManagementRelationTree(
|
||||
relation: UserManagementRelationTreeResponse
|
||||
): Api.SystemManage.UserManagementRelationTreeRespVO {
|
||||
return {
|
||||
...relation,
|
||||
id: normalizeNullableStringId(relation.id),
|
||||
userId: normalizeStringId(relation.userId),
|
||||
managerUserId: normalizeNullableStringId(relation.managerUserId),
|
||||
children: relation.children?.map(normalizeUserManagementRelationTree) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取角色分页 */
|
||||
export function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
|
||||
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
|
||||
const query = createRolePageQuery(params);
|
||||
|
||||
return request<Api.SystemManage.RoleList>({
|
||||
const result = await request<RolePageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${ROLE_PREFIX}/page?${query}` : `${ROLE_PREFIX}/page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RolePageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeRole)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 为兼容旧代码保留原函数名 */
|
||||
export const fetchGetRoleList = fetchGetRolePage;
|
||||
|
||||
/** 获取角色详情 */
|
||||
export function fetchGetRole(id: number) {
|
||||
return request<Api.SystemManage.Role>({
|
||||
export async function fetchGetRole(id: string) {
|
||||
const result = await request<RoleResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${ROLE_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RoleResponse>, normalizeRole);
|
||||
}
|
||||
|
||||
/** 创建角色 */
|
||||
export function fetchCreateRole(data: Api.SystemManage.SaveRoleParams) {
|
||||
return request<number>({
|
||||
export async function fetchCreateRole(data: Api.SystemManage.SaveRoleParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${ROLE_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新角色 */
|
||||
export function fetchUpdateRole(data: { id: number } & Api.SystemManage.SaveRoleParams) {
|
||||
export function fetchUpdateRole(
|
||||
data: { id: string } & Omit<Api.SystemManage.SaveRoleParams, 'scopeType' | 'objectType'>
|
||||
) {
|
||||
return request<boolean>({
|
||||
url: `${ROLE_PREFIX}/update`,
|
||||
method: 'put',
|
||||
@@ -97,7 +236,7 @@ export function fetchUpdateRole(data: { id: number } & Api.SystemManage.SaveRole
|
||||
}
|
||||
|
||||
/** 删除角色 */
|
||||
export function fetchDeleteRole(id: number) {
|
||||
export function fetchDeleteRole(id: string) {
|
||||
return request<boolean>({
|
||||
url: `${ROLE_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
@@ -106,7 +245,7 @@ export function fetchDeleteRole(id: number) {
|
||||
}
|
||||
|
||||
/** 批量删除角色 */
|
||||
export function fetchBatchDeleteRole(ids: number[]) {
|
||||
export function fetchBatchDeleteRole(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${ROLE_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
@@ -119,7 +258,8 @@ export function fetchBatchDeleteRole(ids: number[]) {
|
||||
* 为当前用户页面保留 `roleName / roleCode` 字段,直到该页面完成重构
|
||||
*/
|
||||
export async function fetchGetAllRoles(): Promise<ServiceRequestResult<Api.SystemManage.AllRole[]>> {
|
||||
const result = await request<Api.SystemManage.RoleSimpleList>({
|
||||
const result = await request<RoleSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${ROLE_PREFIX}/simple-list`,
|
||||
method: 'get'
|
||||
});
|
||||
@@ -130,20 +270,28 @@ export async function fetchGetAllRoles(): Promise<ServiceRequestResult<Api.Syste
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map(item => ({
|
||||
...item,
|
||||
roleName: item.name,
|
||||
roleCode: item.code
|
||||
}))
|
||||
data: result.data.map(item => {
|
||||
const role = normalizeRoleSimple(item);
|
||||
|
||||
return {
|
||||
...role,
|
||||
roleName: role.name,
|
||||
roleCode: role.code
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取启用状态的角色简表 */
|
||||
export function fetchGetRoleSimpleList() {
|
||||
return request<Api.SystemManage.RoleSimpleList>({
|
||||
export async function fetchGetRoleSimpleList(params?: Api.SystemManage.ScopeQueryParams) {
|
||||
const result = await request<RoleSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${ROLE_PREFIX}/simple-list`,
|
||||
method: 'get'
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RoleSimpleResponse[]>, data => data.map(normalizeRoleSimple));
|
||||
}
|
||||
|
||||
/** 获取部门列表 */
|
||||
@@ -296,6 +444,17 @@ export function fetchBatchDeletePost(ids: number[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||
export async function fetchGetUserSimpleList() {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/simple-list`,
|
||||
method: 'get'
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取用户分页 */
|
||||
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
||||
return request<Api.SystemManage.UserList>({
|
||||
@@ -308,6 +467,18 @@ export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
||||
/** 为兼容旧代码保留原函数名 */
|
||||
export const fetchGetUserList = fetchGetUserPage;
|
||||
|
||||
/** 通过部门id获取用户详情 */
|
||||
export function fetchGetUserListByDeptId(deptId: any) {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/list-by-dept-id`,
|
||||
method: 'get',
|
||||
params: { deptId }
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取用户详情 */
|
||||
export function fetchGetUser(id: number) {
|
||||
return request<Api.SystemManage.User>({
|
||||
@@ -371,34 +542,45 @@ export function fetchBatchDeleteUser(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取菜单列表 */
|
||||
export function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
|
||||
return request<Api.SystemManage.MenuList>({
|
||||
export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
|
||||
const result = await request<MenuResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${MENU_PREFIX}/list`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<MenuResponse[]>, data => data.map(normalizeMenu));
|
||||
}
|
||||
|
||||
/** 获取菜单详情 */
|
||||
export function fetchGetMenu(id: number) {
|
||||
return request<Api.SystemManage.Menu>({
|
||||
export async function fetchGetMenu(id: string) {
|
||||
const result = await request<MenuResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${MENU_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<MenuResponse>, normalizeMenu);
|
||||
}
|
||||
|
||||
/** 创建菜单 */
|
||||
export function fetchCreateMenu(data: Api.SystemManage.SaveMenuParams) {
|
||||
return request<number>({
|
||||
export async function fetchCreateMenu(data: Api.SystemManage.SaveMenuParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${MENU_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新菜单 */
|
||||
export function fetchUpdateMenu(data: { id: number } & Api.SystemManage.SaveMenuParams) {
|
||||
export function fetchUpdateMenu(
|
||||
data: { id: string } & Omit<Api.SystemManage.SaveMenuParams, 'scopeType' | 'objectType'>
|
||||
) {
|
||||
return request<boolean>({
|
||||
url: `${MENU_PREFIX}/update`,
|
||||
method: 'put',
|
||||
@@ -407,7 +589,7 @@ export function fetchUpdateMenu(data: { id: number } & Api.SystemManage.SaveMenu
|
||||
}
|
||||
|
||||
/** 删除菜单 */
|
||||
export function fetchDeleteMenu(id: number) {
|
||||
export function fetchDeleteMenu(id: string) {
|
||||
return request<boolean>({
|
||||
url: `${MENU_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
@@ -416,7 +598,7 @@ export function fetchDeleteMenu(id: number) {
|
||||
}
|
||||
|
||||
/** 批量删除菜单 */
|
||||
export function fetchBatchDeleteMenu(ids: number[]) {
|
||||
export function fetchBatchDeleteMenu(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${MENU_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
@@ -424,20 +606,27 @@ export function fetchBatchDeleteMenu(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取启用状态的菜单简表 */
|
||||
export function fetchGetMenuSimpleList() {
|
||||
return request<Api.SystemManage.MenuSimpleList>({
|
||||
export async function fetchGetMenuSimpleList(params?: Api.SystemManage.ScopeQueryParams) {
|
||||
const result = await request<MenuSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${MENU_PREFIX}/simple-list`,
|
||||
method: 'get'
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<MenuSimpleResponse[]>, data => data.map(normalizeMenuSimple));
|
||||
}
|
||||
|
||||
/** 获取角色关联的菜单 ID 列表 */
|
||||
export function fetchGetRoleMenuIds(roleId: number) {
|
||||
return request<number[]>({
|
||||
export async function fetchGetRoleMenuIds(roleId: string) {
|
||||
const result = await request<Array<string | number>>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERMISSION_PREFIX}/list-role-menus`,
|
||||
method: 'get',
|
||||
params: { roleId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, data => data.map(normalizeStringId));
|
||||
}
|
||||
|
||||
/** 分配角色菜单 */
|
||||
@@ -450,12 +639,15 @@ export function fetchAssignRoleMenus(data: Api.SystemManage.AssignRoleMenuParams
|
||||
}
|
||||
|
||||
/** 获取用户关联的角色 ID 列表 */
|
||||
export function fetchGetUserRoleIds(userId: number) {
|
||||
return request<number[]>({
|
||||
export async function fetchGetUserRoleIds(userId: number) {
|
||||
const result = await request<Array<string | number>>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PERMISSION_PREFIX}/list-user-roles`,
|
||||
method: 'get',
|
||||
params: { userId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, data => data.map(normalizeStringId));
|
||||
}
|
||||
|
||||
/** 分配用户角色 */
|
||||
@@ -466,3 +658,140 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 用户管理链路相关 API ====================
|
||||
/**
|
||||
* 获取用户管理链路树形结构
|
||||
*
|
||||
* 用于树形控件展示,包含用户的上下级层级关系
|
||||
* 树形结构特点:
|
||||
* - 根节点:最高领导,没有上级
|
||||
* - 中间节点:有上级也有下级
|
||||
* - 叶子节点:基层员工,没有下级
|
||||
*/
|
||||
export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
||||
return request<UserManagementRelationTreeResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
|
||||
method: 'get',
|
||||
params: query
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserManagementRelationTreeResponse[]>, data =>
|
||||
data.map(normalizeUserManagementRelationTree)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过搜索框的查询条件,获取用户管理链路树形结构
|
||||
* 用于树形控件展示,包含用户的上下级层级关系
|
||||
*/
|
||||
export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
||||
return request<UserManagementRelationTreeResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
|
||||
method: 'get',
|
||||
params: query
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserManagementRelationTreeResponse[]>, data =>
|
||||
data.map(normalizeUserManagementRelationTree)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户管理链路详情
|
||||
*
|
||||
* 根据主键 ID 查询单条用户管理链路记录
|
||||
*
|
||||
* @param id 关系记录主键 ID
|
||||
*/
|
||||
export async function fetchGetUserManagementRelation(id: string) {
|
||||
return request<UserManagementRelationResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id }
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserManagementRelationResponse>, normalizeUserManagementRelation)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户管理链路
|
||||
*
|
||||
* 创建新的用户管理链路记录
|
||||
*
|
||||
* @param data 创建请求参数
|
||||
*/
|
||||
export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
||||
return request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
}).then(result => mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户管理链路
|
||||
*
|
||||
* 更新已有的用户管理链路记录
|
||||
*
|
||||
* @param data 更新请求参数(包含 id)
|
||||
*/
|
||||
export function fetchUpdateUserManagementRelation(
|
||||
data: { id: string } & Api.SystemManage.UserManagementRelationSaveReqVO
|
||||
) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户管理链路
|
||||
*
|
||||
* 根据主键 ID 删除单条用户管理链路记录
|
||||
*
|
||||
* @param id 关系记录主键 ID
|
||||
*/
|
||||
export function fetchDeleteUserManagementRelation(id: string | null) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
params: { id }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除用户管理链路
|
||||
*
|
||||
* 根据主键 ID 列表批量删除用户管理链路记录
|
||||
*
|
||||
* @param ids 关系记录主键 ID 列表
|
||||
*/
|
||||
export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未绑定直属上级的候选下级用户列表
|
||||
*
|
||||
* 用于获取尚未绑定直属上级的用户列表,供选择使用
|
||||
*
|
||||
* @returns 候选下级用户列表
|
||||
*/
|
||||
export async function fetchGetCandidateSubordinateUsers() {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/candidate-users`,
|
||||
method: 'get'
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
|
||||
);
|
||||
}
|
||||
|
||||
90
src/service/request/dedupe.ts
Normal file
90
src/service/request/dedupe.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
dedupe?: boolean;
|
||||
/**
|
||||
* 跳过 Authorization 注入。
|
||||
*
|
||||
* 用于公开接口(refresh-token / login / register 等 PermitAll 路径),
|
||||
* 避免给它们带上过期 access 头被网关拦截。
|
||||
*/
|
||||
skipAuth?: boolean;
|
||||
/** 请求失败时不走通用错误 toast,由调用方自行收敛提示。 */
|
||||
suppressErrorMessage?: boolean;
|
||||
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
|
||||
skipTokenRefresh?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
||||
|
||||
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
|
||||
dedupe?: boolean;
|
||||
};
|
||||
|
||||
function isFormDataLike(value: unknown): boolean {
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
export function computeDedupeKey(config: DedupableConfig): string | null {
|
||||
const method = (config.method ?? 'GET').toUpperCase();
|
||||
if (!WRITE_METHODS.has(method)) return null;
|
||||
if (config.dedupe === false) return null;
|
||||
if (isFormDataLike(config.data)) return null;
|
||||
|
||||
const url = config.url ?? '';
|
||||
const paramsPart = stableJson(config.params);
|
||||
const bodyPart = stableJson(config.data);
|
||||
return `${method}|${url}?${paramsPart}|${bodyPart}`;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_MS = 30_000;
|
||||
|
||||
export interface WithDedupeOptions {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
|
||||
|
||||
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
|
||||
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
|
||||
|
||||
return new Proxy(request, {
|
||||
apply(target, thisArg, args: Parameters<TFn>) {
|
||||
const [config] = args;
|
||||
const key = computeDedupeKey(config as DedupableConfig);
|
||||
if (key === null) return Reflect.apply(target, thisArg, args);
|
||||
|
||||
const cached = pending.get(key);
|
||||
if (cached && cached.expiresAt > now()) return cached.promise;
|
||||
if (cached) pending.delete(key);
|
||||
|
||||
const promise = Promise.resolve()
|
||||
.then(() => Reflect.apply(target, thisArg, args))
|
||||
.finally(() => {
|
||||
const current = pending.get(key);
|
||||
if (current && current.promise === promise) {
|
||||
pending.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
pending.set(key, { promise, expiresAt: now() + ttl });
|
||||
return promise;
|
||||
}
|
||||
}) as TFn;
|
||||
}
|
||||
32
src/service/request/error-message.ts
Normal file
32
src/service/request/error-message.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const SESSION_EXPIRED_MESSAGE = '登录已失效,请重新登录';
|
||||
|
||||
export interface ErrorMessageSuppressOptions {
|
||||
backendErrorCode: string;
|
||||
suppressErrorMessage?: boolean;
|
||||
logoutCodes: string[];
|
||||
modalLogoutCodes: string[];
|
||||
expiredTokenCodes: string[];
|
||||
}
|
||||
|
||||
export interface BackendFailDeferOptions {
|
||||
suppressErrorMessage?: boolean;
|
||||
skipTokenRefresh?: boolean;
|
||||
}
|
||||
|
||||
export function parseServiceCodes(codes?: string) {
|
||||
return codes?.split(',').filter(Boolean) || [];
|
||||
}
|
||||
|
||||
export function shouldDeferBackendFailToCaller(options: BackendFailDeferOptions) {
|
||||
return Boolean(options.suppressErrorMessage && options.skipTokenRefresh);
|
||||
}
|
||||
|
||||
export function shouldSuppressErrorMessage(options: ErrorMessageSuppressOptions) {
|
||||
if (options.suppressErrorMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const handledCodes = [...options.logoutCodes, ...options.modalLogoutCodes, ...options.expiredTokenCodes];
|
||||
|
||||
return handledCodes.includes(options.backendErrorCode);
|
||||
}
|
||||
@@ -5,126 +5,153 @@ import { localStg } from '@/utils/storage';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { $t } from '@/locales';
|
||||
import { applyApiEncrypt } from './api-encrypt';
|
||||
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
|
||||
import { parseServiceCodes, shouldDeferBackendFailToCaller, shouldSuppressErrorMessage } from './error-message';
|
||||
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
|
||||
import { withDedupe } from './dedupe';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
|
||||
export const request = createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
headers: {
|
||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||
}
|
||||
},
|
||||
{
|
||||
defaultState: {
|
||||
errMsgStack: [],
|
||||
refreshTokenPromise: null
|
||||
} as RequestInstanceState,
|
||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||
return response.data.data;
|
||||
export const request = withDedupe(
|
||||
createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
headers: {
|
||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||
}
|
||||
},
|
||||
async onRequest(config) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(config.headers, { Authorization });
|
||||
applyApiEncrypt(config);
|
||||
|
||||
return config;
|
||||
},
|
||||
isBackendSuccess(response) {
|
||||
// 当后端返回码为 "0"(默认)时,表示请求成功
|
||||
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
|
||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
|
||||
},
|
||||
async onBackendFail(response, instance) {
|
||||
const authStore = useAuthStore();
|
||||
const responseCode = String(response.data.code);
|
||||
|
||||
function handleLogout() {
|
||||
authStore.resetStore();
|
||||
}
|
||||
|
||||
function logoutAndCleanup() {
|
||||
handleLogout();
|
||||
window.removeEventListener('beforeunload', handleLogout);
|
||||
|
||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
|
||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
handleLogout();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
||||
|
||||
// 防止用户刷新页面绕过退出逻辑
|
||||
window.addEventListener('beforeunload', handleLogout);
|
||||
|
||||
window.$messageBox
|
||||
?.confirm(response.data.msg, $t('common.error'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'error',
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false
|
||||
})
|
||||
.then(() => {
|
||||
logoutAndCleanup();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
||||
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
||||
if (expiredTokenCodes.includes(responseCode)) {
|
||||
const success = await handleExpiredRequest(request.state);
|
||||
if (success) {
|
||||
{
|
||||
defaultState: {
|
||||
errMsgStack: [],
|
||||
refreshTokenPromise: null
|
||||
} as RequestInstanceState,
|
||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||
return response.data.data;
|
||||
},
|
||||
async onRequest(config) {
|
||||
// skipAuth 为 true 的请求不注入 Authorization——避免给公开接口(如 refresh-token)
|
||||
// 带上过期 access 头被网关拦截(网关只看 Authorization,不区分路由是否 PermitAll)
|
||||
if (!config.skipAuth) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(response.config.headers, { Authorization });
|
||||
|
||||
return instance.request(response.config) as Promise<AxiosResponse>;
|
||||
Object.assign(config.headers, { Authorization });
|
||||
}
|
||||
applyApiEncrypt(config);
|
||||
|
||||
return config;
|
||||
},
|
||||
isBackendSuccess(response) {
|
||||
// 当后端返回码为 "0"(默认)时,表示请求成功
|
||||
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
|
||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
|
||||
},
|
||||
async onBackendFail(response, instance) {
|
||||
const authStore = useAuthStore();
|
||||
const responseCode = String(response.data.code);
|
||||
|
||||
if (
|
||||
shouldDeferBackendFailToCaller({
|
||||
suppressErrorMessage: response.config.suppressErrorMessage,
|
||||
skipTokenRefresh: response.config.skipTokenRefresh
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.resetStore();
|
||||
}
|
||||
|
||||
function logoutAndCleanup() {
|
||||
handleLogout();
|
||||
window.removeEventListener('beforeunload', handleLogout);
|
||||
|
||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
|
||||
// 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态
|
||||
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
notifySessionExpired();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
||||
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
||||
|
||||
// 防止用户刷新页面绕过退出逻辑
|
||||
window.addEventListener('beforeunload', handleLogout);
|
||||
|
||||
window.$messageBox
|
||||
?.confirm(response.data.msg, $t('common.error'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'error',
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false
|
||||
})
|
||||
.then(() => {
|
||||
logoutAndCleanup();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
||||
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
||||
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||
if (expiredTokenCodes.includes(responseCode)) {
|
||||
if (response.config.skipTokenRefresh) {
|
||||
notifySessionExpired();
|
||||
return null;
|
||||
}
|
||||
|
||||
const success = await handleExpiredRequest(request.state);
|
||||
if (success) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(response.config.headers, { Authorization });
|
||||
|
||||
return instance.request(response.config) as Promise<AxiosResponse>;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onError(error) {
|
||||
// 请求失败时,在这里统一处理错误提示
|
||||
|
||||
let message = error.message;
|
||||
let backendErrorCode = '';
|
||||
|
||||
// 获取后端错误信息和错误码
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.msg || message;
|
||||
backendErrorCode = String(error.response?.data?.code || '');
|
||||
}
|
||||
|
||||
const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
|
||||
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||
if (
|
||||
shouldSuppressErrorMessage({
|
||||
backendErrorCode,
|
||||
suppressErrorMessage,
|
||||
logoutCodes,
|
||||
modalLogoutCodes,
|
||||
expiredTokenCodes
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorMsg(request.state, message);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onError(error) {
|
||||
// 请求失败时,在这里统一处理错误提示
|
||||
|
||||
let message = error.message;
|
||||
let backendErrorCode = '';
|
||||
|
||||
// 获取后端错误信息和错误码
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.msg || message;
|
||||
backendErrorCode = String(error.response?.data?.code || '');
|
||||
}
|
||||
|
||||
// 这类错误信息已经通过弹窗展示,不再重复提示
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// token 过期时会自动刷新并重试请求,这里无需额外提示
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorMsg(request.state, message);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const demoRequest = createRequest(
|
||||
|
||||
84
src/service/request/json.ts
Normal file
84
src/service/request/json.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
function shouldStringifyUnsafeInteger(token: string) {
|
||||
if (token.includes('.') || token.includes('e') || token.includes('E')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = BigInt(token);
|
||||
return value > MAX_SAFE_INTEGER_BIGINT || value < -MAX_SAFE_INTEGER_BIGINT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceUnsafeIntegerTokens(raw: string) {
|
||||
let result = '';
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
let isEscaping = false;
|
||||
|
||||
while (index < raw.length) {
|
||||
const char = raw[index];
|
||||
|
||||
if (inString) {
|
||||
result += char;
|
||||
|
||||
if (isEscaping) {
|
||||
isEscaping = false;
|
||||
} else if (char === '\\') {
|
||||
isEscaping = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
inString = true;
|
||||
result += char;
|
||||
index += 1;
|
||||
} else {
|
||||
const nextChar = raw[index + 1] ?? '';
|
||||
const isNumberStart = char === '-' ? /\d/.test(nextChar) : /\d/.test(char);
|
||||
|
||||
if (!isNumberStart) {
|
||||
result += char;
|
||||
index += 1;
|
||||
} else {
|
||||
let end = index + 1;
|
||||
|
||||
while (end < raw.length && /[\d.+\-Ee]/.test(raw[end])) {
|
||||
end += 1;
|
||||
}
|
||||
|
||||
const token = raw.slice(index, end);
|
||||
result += shouldStringifyUnsafeInteger(token) ? `"${token}"` : token;
|
||||
index = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留超出 JS 安全整数范围的 Long 原始值,避免在 JSON.parse 阶段丢精度。
|
||||
*/
|
||||
export function safeJsonTransformResponse(data: unknown) {
|
||||
if (typeof data !== 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const raw = data.trim();
|
||||
|
||||
if (!raw) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(replaceUnsafeIntegerTokens(raw));
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { fetchRefreshToken } from '../api';
|
||||
import { SESSION_EXPIRED_MESSAGE } from './error-message';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
||||
export function getAuthorization() {
|
||||
@@ -12,8 +13,6 @@ export function getAuthorization() {
|
||||
|
||||
/** 刷新 token */
|
||||
async function handleRefreshToken() {
|
||||
const { resetStore } = useAuthStore();
|
||||
|
||||
const rToken = localStg.get('refreshToken') || '';
|
||||
const { error, data } = await fetchRefreshToken(rToken);
|
||||
if (!error) {
|
||||
@@ -22,25 +21,48 @@ async function handleRefreshToken() {
|
||||
return true;
|
||||
}
|
||||
|
||||
resetStore();
|
||||
notifySessionExpired();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
if (!state.refreshTokenFn) {
|
||||
state.refreshTokenFn = handleRefreshToken();
|
||||
if (!state.refreshTokenPromise) {
|
||||
state.refreshTokenPromise = handleRefreshToken();
|
||||
}
|
||||
|
||||
const success = await state.refreshTokenFn;
|
||||
const success = await state.refreshTokenPromise;
|
||||
|
||||
setTimeout(() => {
|
||||
state.refreshTokenFn = null;
|
||||
state.refreshTokenPromise = null;
|
||||
}, 1000);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次
|
||||
let sessionExpiredNotified = false;
|
||||
|
||||
/**
|
||||
* 通知用户会话已失效,弹一次 toast 后清状态、跳登录。
|
||||
*
|
||||
* 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。
|
||||
*/
|
||||
export function notifySessionExpired() {
|
||||
if (sessionExpiredNotified) return;
|
||||
sessionExpiredNotified = true;
|
||||
|
||||
window.$message?.error(SESSION_EXPIRED_MESSAGE);
|
||||
|
||||
const { resetStore } = useAuthStore();
|
||||
resetStore();
|
||||
}
|
||||
|
||||
/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */
|
||||
export function resetSessionExpiredFlag() {
|
||||
sessionExpiredNotified = false;
|
||||
}
|
||||
|
||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||
if (!state.errMsgStack?.length) {
|
||||
state.errMsgStack = [];
|
||||
|
||||
@@ -3,5 +3,7 @@ export interface RequestInstanceState {
|
||||
refreshTokenPromise: Promise<boolean> | null;
|
||||
/** 请求错误信息栈 */
|
||||
errMsgStack: string[];
|
||||
// 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record<string, unknown>)的硬要求,不能删
|
||||
// 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
|
||||
import { breakpointsTailwind, useBreakpoints, useEventListener, useTitle } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { router } from '@/router';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { getGlobalRouter } from '@/router/instance';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { $t, setLocale } from '@/locales';
|
||||
import { setDayjsLocale } from '@/locales/dayjs';
|
||||
@@ -63,7 +63,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
|
||||
|
||||
/** Update document title by locale */
|
||||
function updateDocumentTitleByLocale() {
|
||||
const { i18nKey, title } = router.currentRoute.value.meta;
|
||||
const { i18nKey, title } = getGlobalRouter().currentRoute.value.meta;
|
||||
|
||||
const documentTitle = i18nKey ? $t(i18nKey) : title;
|
||||
|
||||
@@ -83,11 +83,10 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
|
||||
if (newValue) {
|
||||
// backup theme setting before is mobile
|
||||
localStg.set('backupThemeSettingBeforeIsMobile', {
|
||||
layout: themeStore.layout.mode,
|
||||
layout: themeStore.layoutMode,
|
||||
siderCollapse: siderCollapse.value
|
||||
});
|
||||
|
||||
themeStore.setThemeLayout('vertical');
|
||||
setSiderCollapse(true);
|
||||
} else {
|
||||
// when is not mobile, recover the backup theme setting
|
||||
@@ -95,7 +94,6 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
|
||||
|
||||
if (backup) {
|
||||
nextTick(() => {
|
||||
themeStore.setThemeLayout(backup.layout);
|
||||
setSiderCollapse(backup.siderCollapse);
|
||||
|
||||
localStg.remove('backupThemeSettingBeforeIsMobile');
|
||||
|
||||
@@ -3,12 +3,15 @@ import { useRoute } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
|
||||
import { resetSessionExpiredFlag } from '@/service/request/shared';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { $t } from '@/locales';
|
||||
import { useDictStore } from '../dict';
|
||||
import { useRouteStore } from '../route';
|
||||
import { useTabStore } from '../tab';
|
||||
import { useObjectContextStore } from '../object-context';
|
||||
import { clearAuthStorage, getToken } from './shared';
|
||||
|
||||
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
@@ -16,6 +19,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const authStore = useAuthStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
const dictStore = useDictStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const { toLogin, redirectFromLogin } = useRouterPush(false);
|
||||
const { loading: loginLoading, startLoading, endLoading } = useLoading();
|
||||
|
||||
@@ -24,6 +29,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const userInfo: Api.Auth.UserInfo = reactive({
|
||||
userId: '',
|
||||
userName: '',
|
||||
nickname: '',
|
||||
roles: [],
|
||||
buttons: []
|
||||
});
|
||||
@@ -45,14 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
authStore.$reset();
|
||||
// setup store 没有内置 $reset,需要显式重置内部状态,避免 token / userInfo 残留导致 isLogin 误判。
|
||||
token.value = '';
|
||||
Object.assign(userInfo, {
|
||||
userId: '',
|
||||
userName: '',
|
||||
nickname: '',
|
||||
roles: [],
|
||||
buttons: []
|
||||
});
|
||||
|
||||
if (!route.meta.constant) {
|
||||
dictStore.resetDictCache();
|
||||
objectContextStore.clearContext();
|
||||
|
||||
// 用路由名判断当前是否已在登录页,避免依赖 route.meta.constant ——
|
||||
// workbench 等首页也是常量路由,原写法会让常量路由上的登出请求不跳转。
|
||||
if (route.name !== 'login') {
|
||||
await toLogin();
|
||||
}
|
||||
|
||||
tabStore.cacheTabs();
|
||||
routeStore.resetStore();
|
||||
await routeStore.resetStore();
|
||||
}
|
||||
|
||||
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
||||
@@ -138,8 +157,13 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const pass = await getUserInfo();
|
||||
|
||||
if (pass) {
|
||||
await dictStore.initDictCache(true);
|
||||
|
||||
token.value = loginToken.token;
|
||||
|
||||
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
|
||||
resetSessionExpiredFlag();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -159,6 +183,18 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function refreshUserInfo() {
|
||||
const { data: info, error } = await fetchGetUserInfo(true);
|
||||
|
||||
if (!error) {
|
||||
Object.assign(userInfo, info);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function initUserInfo() {
|
||||
const hasToken = getToken();
|
||||
|
||||
@@ -181,6 +217,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
loginLoading,
|
||||
resetStore,
|
||||
login,
|
||||
initUserInfo
|
||||
initUserInfo,
|
||||
refreshUserInfo
|
||||
};
|
||||
});
|
||||
|
||||
279
src/store/modules/dict/index.ts
Normal file
279
src/store/modules/dict/index.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
|
||||
type DictValue = string | number | null | undefined;
|
||||
type DictFilterOptions = {
|
||||
onlyEnabled?: boolean;
|
||||
};
|
||||
type DictLabelOptions = DictFilterOptions & {
|
||||
fallback?: string;
|
||||
};
|
||||
type DictLabelsOptions = DictLabelOptions & {
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
function sortDictData(list: Api.Dict.DictData[]) {
|
||||
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
// hex 色值兜底校验:仅接受 #RRGGBB(6 位);其他格式(含 #RGB 简写 / rgb())一律视为无效回落到默认渲染
|
||||
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function normalizeColorType(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeFrontendDictData(
|
||||
dictType: string,
|
||||
list: Api.Dict.FrontendDictData[],
|
||||
dictIndex: number
|
||||
): Api.Dict.DictData[] {
|
||||
const normalizedList = list.map((item, itemIndex) => ({
|
||||
id: -((dictIndex + 1) * 100000 + itemIndex + 1),
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
dictType: item.dictType || dictType,
|
||||
sort: item.sort,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null,
|
||||
createTime: 0
|
||||
}));
|
||||
|
||||
return sortDictData(normalizedList);
|
||||
}
|
||||
|
||||
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||
return {
|
||||
...item,
|
||||
value: String(item.value),
|
||||
dictType: item.dictType || dictType,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||
const entries = Object.entries(cache);
|
||||
|
||||
return Object.fromEntries(
|
||||
entries.map(([dictType, list], index) => [dictType, normalizeFrontendDictData(dictType, list, index)])
|
||||
);
|
||||
}
|
||||
|
||||
function applyDictTypeAliases(dictDataMap: Record<string, Api.Dict.DictData[]>) {
|
||||
const nextDictDataMap = { ...dictDataMap };
|
||||
|
||||
// 兼容后端尚未切换完成的过渡期:旧编码仍返回时,前端统一映射到新编码。
|
||||
if (!nextDictDataMap[RDMS_OBJECT_DIRECTION_DICT_CODE] && nextDictDataMap[RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE]) {
|
||||
nextDictDataMap[RDMS_OBJECT_DIRECTION_DICT_CODE] = nextDictDataMap[RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE].map(
|
||||
item => ({
|
||||
...item,
|
||||
dictType: RDMS_OBJECT_DIRECTION_DICT_CODE
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return nextDictDataMap;
|
||||
}
|
||||
|
||||
function createRuntimeDictTypes(dictDataMap: Record<string, Api.Dict.DictData[]>) {
|
||||
return Object.keys(dictDataMap).map((dictType, index) => ({
|
||||
id: -(index + 1),
|
||||
name: dictType,
|
||||
type: dictType,
|
||||
status: 0 as const,
|
||||
remark: null,
|
||||
createTime: 0
|
||||
}));
|
||||
}
|
||||
|
||||
function findDictItem(list: Api.Dict.DictData[], value?: DictValue) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return list.find(item => item.value === String(value));
|
||||
}
|
||||
|
||||
export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
const loading = ref(false);
|
||||
const initialized = ref(false);
|
||||
const dictTypes = ref<Api.Dict.DictType[]>([]);
|
||||
const dictDataMap = ref<Record<string, Api.Dict.DictData[]>>({});
|
||||
const loadedAt = ref<number | null>(null);
|
||||
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
function resetDictCache() {
|
||||
dictTypes.value = [];
|
||||
dictDataMap.value = {};
|
||||
loadedAt.value = null;
|
||||
initialized.value = false;
|
||||
initPromise = null;
|
||||
dictDataLoadPromises.clear();
|
||||
}
|
||||
|
||||
async function initDictCache(force = false) {
|
||||
if (initialized.value && !force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (initPromise && !force) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
if (force) {
|
||||
resetDictCache();
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
loading.value = true;
|
||||
|
||||
const result = await fetchGetFrontendDictCache();
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (result.error) {
|
||||
initPromise = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDictDataMap = applyDictTypeAliases(normalizeFrontendDictCache(result.data || {}));
|
||||
|
||||
dictTypes.value = createRuntimeDictTypes(normalizedDictDataMap);
|
||||
dictDataMap.value = normalizedDictDataMap;
|
||||
loadedAt.value = Date.now();
|
||||
initialized.value = true;
|
||||
initPromise = null;
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function ensureDictData(dictType: string, force = false) {
|
||||
if (!dictType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await initDictCache();
|
||||
}
|
||||
|
||||
if (!force && getDictData(dictType).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = dictDataLoadPromises.get(dictType);
|
||||
if (pending && !force) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchGetDictDataByCode(dictType);
|
||||
|
||||
if (result.error || !result.data?.list?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dictDataMap.value = {
|
||||
...dictDataMap.value,
|
||||
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||
};
|
||||
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
dictDataLoadPromises.set(dictType, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||
dictDataLoadPromises.delete(dictType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDictData(dictType: string, onlyEnabled = false) {
|
||||
if (!dictType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const list = dictDataMap.value[dictType] || [];
|
||||
|
||||
if (!onlyEnabled) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return list.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getDictOptions(dictType: string, onlyEnabled = true) {
|
||||
return getDictData(dictType, onlyEnabled).map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}));
|
||||
}
|
||||
|
||||
function getDictItem(dictType: string, value?: DictValue, options: DictFilterOptions = {}) {
|
||||
return findDictItem(getDictData(dictType, options.onlyEnabled), value);
|
||||
}
|
||||
|
||||
function getDictLabel(dictType: string, value?: DictValue, options: DictLabelOptions = {}) {
|
||||
const { fallback = '--', onlyEnabled = false } = options;
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const matched = getDictItem(dictType, value, { onlyEnabled });
|
||||
|
||||
return matched?.label || String(value);
|
||||
}
|
||||
|
||||
function getDictLabels(dictType: string, values?: Array<DictValue> | null, options: DictLabelsOptions = {}) {
|
||||
const { fallback = '--', separator = ' / ', onlyEnabled = false } = options;
|
||||
|
||||
if (!values?.length) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const labels = values
|
||||
.filter(value => value !== null && value !== undefined && value !== '')
|
||||
.map(value => getDictLabel(dictType, value, { fallback: String(value), onlyEnabled }));
|
||||
|
||||
return labels.length ? labels.join(separator) : fallback;
|
||||
}
|
||||
|
||||
function hasDictValue(dictType: string, value?: DictValue, options: DictFilterOptions = {}) {
|
||||
return Boolean(getDictItem(dictType, value, options));
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
initialized,
|
||||
dictTypes,
|
||||
dictDataMap,
|
||||
loadedAt,
|
||||
initDictCache,
|
||||
ensureDictData,
|
||||
resetDictCache,
|
||||
getDictData,
|
||||
getDictOptions,
|
||||
getDictItem,
|
||||
getDictLabel,
|
||||
getDictLabels,
|
||||
hasDictValue
|
||||
};
|
||||
});
|
||||
403
src/store/modules/object-context/index.ts
Normal file
403
src/store/modules/object-context/index.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import type { LocationQueryRaw, RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { ElegantConstRoute } from '@elegant-router/types';
|
||||
import {
|
||||
OBJECT_CONTEXT_QUERY_KEY,
|
||||
getObjectContextDomainConfigByPath,
|
||||
isObjectContextEntryPath
|
||||
} from '@/constants/object-context';
|
||||
import { fetchGetObjectContext } from '@/service/api/object-context';
|
||||
import { $t } from '@/locales';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { useRouteStore } from '../route';
|
||||
|
||||
function createEmptyState(): App.ObjectContext.State {
|
||||
return {
|
||||
domainKey: '',
|
||||
objectType: '',
|
||||
objectId: '',
|
||||
objectName: '',
|
||||
objectSummary: null,
|
||||
contextScopedMenus: [],
|
||||
buttonCodes: [],
|
||||
defaultRouteKey: '',
|
||||
defaultRoutePath: '',
|
||||
isReady: false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
function isRouteMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const normalizedPrefix = normalizePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
function findDomainRootRoute(
|
||||
routes: ElegantConstRoute[],
|
||||
config: App.ObjectContext.DomainConfig
|
||||
): ElegantConstRoute | null {
|
||||
for (const route of routes) {
|
||||
if (config.routePathPrefixes.some(prefix => isRouteMatchedByPrefix(route.path, prefix))) {
|
||||
return route;
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
const matchedChild = findDomainRootRoute(route.children, config);
|
||||
|
||||
if (matchedChild) {
|
||||
return matchedChild;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEntryRoute(route: ElegantConstRoute, config: App.ObjectContext.DomainConfig) {
|
||||
return route.name === config.entryRouteKey || normalizePath(route.path) === normalizePath(config.entryRoutePath);
|
||||
}
|
||||
|
||||
function getContextMenuLabel(route: ElegantConstRoute) {
|
||||
const routeName = String(route.name || route.path);
|
||||
|
||||
return route.meta?.i18nKey ? $t(route.meta.i18nKey) : String(route.meta?.title || routeName);
|
||||
}
|
||||
|
||||
type ContextRouteLookupItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
routeKey: string | null;
|
||||
routePath: string | null;
|
||||
};
|
||||
|
||||
function createContextRouteLookup(
|
||||
routes: ElegantConstRoute[],
|
||||
lookup = new Map<string, ContextRouteLookupItem>()
|
||||
): Map<string, ContextRouteLookupItem> {
|
||||
routes.forEach(route => {
|
||||
const routeName = route.name ? String(route.name) : '';
|
||||
const routePath = route.path ? String(route.path) : '';
|
||||
const item: ContextRouteLookupItem = {
|
||||
key: routeName || routePath,
|
||||
label: getContextMenuLabel(route),
|
||||
routeKey: routeName || null,
|
||||
routePath: routePath || null
|
||||
};
|
||||
|
||||
if (routeName) {
|
||||
lookup.set(routeName, item);
|
||||
}
|
||||
|
||||
if (routePath) {
|
||||
lookup.set(routePath, item);
|
||||
}
|
||||
|
||||
route.children?.forEach(child => {
|
||||
createContextRouteLookup([child], lookup);
|
||||
});
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function enrichContextMenu(
|
||||
menu: App.ObjectContext.Menu,
|
||||
routeLookup: Map<string, ContextRouteLookupItem>
|
||||
): App.ObjectContext.Menu {
|
||||
const matchedRoute =
|
||||
routeLookup.get(String(menu.routeKey || '')) ||
|
||||
routeLookup.get(String(menu.routePath || '')) ||
|
||||
routeLookup.get(menu.key);
|
||||
|
||||
return {
|
||||
key: matchedRoute?.key || menu.key,
|
||||
label: menu.label || matchedRoute?.label || menu.key,
|
||||
routeKey: menu.routeKey || matchedRoute?.routeKey || null,
|
||||
routePath: menu.routePath || matchedRoute?.routePath || null,
|
||||
children: menu.children?.map(child => enrichContextMenu(child, routeLookup)) || []
|
||||
};
|
||||
}
|
||||
|
||||
function getLeafRoutes(routes: ElegantConstRoute[]): ElegantConstRoute[] {
|
||||
return routes.flatMap(route => {
|
||||
if (route.children?.length) {
|
||||
return getLeafRoutes(route.children);
|
||||
}
|
||||
|
||||
return [route];
|
||||
});
|
||||
}
|
||||
|
||||
export const useObjectContextStore = defineStore(SetupStoreId.ObjectContext, () => {
|
||||
const routeStore = useRouteStore();
|
||||
const domainKey = ref<App.ObjectContext.DomainKey>('');
|
||||
const objectType = ref<App.ObjectContext.ObjectType>('');
|
||||
const objectId = ref('');
|
||||
const objectName = ref('');
|
||||
const objectSummary = ref<App.ObjectContext.Summary | null>(null);
|
||||
const contextScopedMenus = ref<App.ObjectContext.Menu[]>([]);
|
||||
const buttonCodes = ref<string[]>([]);
|
||||
const defaultRouteKey = ref('');
|
||||
const defaultRoutePath = ref('');
|
||||
const isReady = ref(false);
|
||||
|
||||
const hasContext = computed(() => isReady.value && Boolean(domainKey.value) && Boolean(objectId.value));
|
||||
|
||||
function patchState(state: App.ObjectContext.State) {
|
||||
domainKey.value = state.domainKey;
|
||||
objectType.value = state.objectType;
|
||||
objectId.value = state.objectId;
|
||||
objectName.value = state.objectName;
|
||||
objectSummary.value = state.objectSummary;
|
||||
contextScopedMenus.value = state.contextScopedMenus;
|
||||
buttonCodes.value = state.buttonCodes;
|
||||
defaultRouteKey.value = state.defaultRouteKey;
|
||||
defaultRoutePath.value = state.defaultRoutePath;
|
||||
isReady.value = state.isReady;
|
||||
}
|
||||
|
||||
function clearContext() {
|
||||
patchState(createEmptyState());
|
||||
}
|
||||
|
||||
function resolveDefaultRoute(
|
||||
config: App.ObjectContext.DomainConfig,
|
||||
domainRoutes: ElegantConstRoute[],
|
||||
context: Api.ObjectContext.ContextInfo
|
||||
) {
|
||||
const leafRoutes = getLeafRoutes(domainRoutes);
|
||||
const defaultRouteByKey = (routeKey?: string | null) => leafRoutes.find(route => route.name === routeKey);
|
||||
const defaultRouteByPath = (routePath?: string | null) =>
|
||||
leafRoutes.find(route => normalizePath(route.path) === normalizePath(routePath || ''));
|
||||
|
||||
const matchedContextByKey = defaultRouteByKey(context.defaultRouteKey);
|
||||
|
||||
if (matchedContextByKey?.name && matchedContextByKey.path) {
|
||||
return {
|
||||
defaultRouteKey: String(matchedContextByKey.name),
|
||||
defaultRoutePath: matchedContextByKey.path
|
||||
};
|
||||
}
|
||||
|
||||
const matchedContextByPath = defaultRouteByPath(context.defaultRoutePath);
|
||||
|
||||
if (matchedContextByPath?.name && matchedContextByPath.path) {
|
||||
return {
|
||||
defaultRouteKey: String(matchedContextByPath.name),
|
||||
defaultRoutePath: matchedContextByPath.path
|
||||
};
|
||||
}
|
||||
|
||||
const matchedFallbackByKey = defaultRouteByKey(config.fallbackDefaultRouteKey);
|
||||
|
||||
if (matchedFallbackByKey?.name && matchedFallbackByKey.path) {
|
||||
return {
|
||||
defaultRouteKey: String(matchedFallbackByKey.name),
|
||||
defaultRoutePath: matchedFallbackByKey.path
|
||||
};
|
||||
}
|
||||
|
||||
const matchedFallbackByPath = defaultRouteByPath(config.fallbackDefaultRoutePath);
|
||||
|
||||
if (matchedFallbackByPath?.name && matchedFallbackByPath.path) {
|
||||
return {
|
||||
defaultRouteKey: String(matchedFallbackByPath.name),
|
||||
defaultRoutePath: matchedFallbackByPath.path
|
||||
};
|
||||
}
|
||||
|
||||
const [firstLeafRoute] = leafRoutes;
|
||||
|
||||
if (firstLeafRoute?.name && firstLeafRoute.path) {
|
||||
return {
|
||||
defaultRouteKey: String(firstLeafRoute.name),
|
||||
defaultRoutePath: firstLeafRoute.path
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
defaultRouteKey: context.defaultRouteKey || config.fallbackDefaultRouteKey,
|
||||
defaultRoutePath: context.defaultRoutePath || config.fallbackDefaultRoutePath
|
||||
};
|
||||
}
|
||||
|
||||
function applyContext(config: App.ObjectContext.DomainConfig, context: Api.ObjectContext.ContextInfo) {
|
||||
const domainRootRoute = findDomainRootRoute(routeStore.authRoutes, config);
|
||||
const domainRoutes: ElegantConstRoute[] =
|
||||
domainRootRoute?.children?.filter((route: ElegantConstRoute) => !isEntryRoute(route, config)) || [];
|
||||
const routeLookup = createContextRouteLookup(domainRoutes);
|
||||
// 对象上下文菜单以接口返回为准,前端只补全跳转所需的本地路由信息。
|
||||
const contextMenus = context.contextScopedMenus.map(menu => enrichContextMenu(menu, routeLookup));
|
||||
const resolvedDefaultRoute = resolveDefaultRoute(config, domainRoutes, context);
|
||||
|
||||
patchState({
|
||||
...context,
|
||||
contextScopedMenus: contextMenus,
|
||||
defaultRouteKey: resolvedDefaultRoute.defaultRouteKey,
|
||||
defaultRoutePath: resolvedDefaultRoute.defaultRoutePath,
|
||||
isReady: true
|
||||
});
|
||||
}
|
||||
|
||||
function getObjectIdFromRoute(route: Pick<RouteLocationNormalized, 'query'>) {
|
||||
const routeObjectId = route.query?.[OBJECT_CONTEXT_QUERY_KEY];
|
||||
|
||||
if (Array.isArray(routeObjectId)) {
|
||||
return String(routeObjectId[0] || '');
|
||||
}
|
||||
|
||||
if (routeObjectId === null || routeObjectId === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(routeObjectId);
|
||||
}
|
||||
|
||||
function getContextQuery(targetObjectId = objectId.value): LocationQueryRaw {
|
||||
if (!targetObjectId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: targetObjectId
|
||||
};
|
||||
}
|
||||
|
||||
function createEntryLocation(config: App.ObjectContext.DomainConfig): RouteLocationRaw {
|
||||
return {
|
||||
path: config.entryRoutePath
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultLocation(config: App.ObjectContext.DomainConfig, targetObjectId: string): RouteLocationRaw {
|
||||
const query = getContextQuery(targetObjectId);
|
||||
|
||||
if (defaultRouteKey.value) {
|
||||
return {
|
||||
name: defaultRouteKey.value,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
if (defaultRoutePath.value) {
|
||||
return {
|
||||
path: defaultRoutePath.value,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: config.fallbackDefaultRoutePath,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
async function enterContext(config: App.ObjectContext.DomainConfig, targetObjectId: string) {
|
||||
const result = await fetchGetObjectContext(config, targetObjectId);
|
||||
|
||||
if (!result.error && result.data) {
|
||||
applyContext(config, result.data);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function switchContext(config: App.ObjectContext.DomainConfig, targetObjectId: string) {
|
||||
return enterContext(config, targetObjectId);
|
||||
}
|
||||
|
||||
async function ensureContextByRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw | null> {
|
||||
const domainConfig = getObjectContextDomainConfigByPath(to.path);
|
||||
|
||||
if (!domainConfig) {
|
||||
if (hasContext.value) {
|
||||
clearContext();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const routeObjectId = getObjectIdFromRoute(to);
|
||||
|
||||
if (!routeObjectId) {
|
||||
clearContext();
|
||||
|
||||
if (isObjectContextEntryPath(to.path, domainConfig)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createEntryLocation(domainConfig);
|
||||
}
|
||||
|
||||
const isSameContext =
|
||||
hasContext.value && domainKey.value === domainConfig.domainKey && objectId.value === routeObjectId;
|
||||
|
||||
if (!isSameContext) {
|
||||
const { error } = await enterContext(domainConfig, routeObjectId);
|
||||
|
||||
if (error) {
|
||||
clearContext();
|
||||
return createEntryLocation(domainConfig);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObjectContextEntryPath(to.path, domainConfig)) {
|
||||
return createDefaultLocation(domainConfig, routeObjectId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMenuRouteLocation(
|
||||
menu: App.ObjectContext.Menu,
|
||||
targetObjectId = objectId.value
|
||||
): RouteLocationRaw | null {
|
||||
const query = getContextQuery(targetObjectId);
|
||||
|
||||
if (menu.routeKey) {
|
||||
return {
|
||||
name: menu.routeKey,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
if (menu.routePath) {
|
||||
return {
|
||||
path: menu.routePath,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
domainKey,
|
||||
objectType,
|
||||
objectId,
|
||||
objectName,
|
||||
objectSummary,
|
||||
contextScopedMenus,
|
||||
buttonCodes,
|
||||
defaultRouteKey,
|
||||
defaultRoutePath,
|
||||
isReady,
|
||||
hasContext,
|
||||
clearContext,
|
||||
enterContext,
|
||||
switchContext,
|
||||
ensureContextByRoute,
|
||||
getContextQuery,
|
||||
getMenuRouteLocation
|
||||
};
|
||||
});
|
||||
@@ -3,13 +3,12 @@ import type { RouteRecordRaw } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { CustomRoute, ElegantConstRoute, LastLevelRouteKey, RouteKey, RouteMap } from '@elegant-router/types';
|
||||
import { router } from '@/router';
|
||||
import { fetchGetUserRoutes, fetchIsRouteExist } from '@/service/api';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { createStaticRoutes, getAuthVueRoutes } from '@/router/routes';
|
||||
import { ROOT_ROUTE } from '@/router/routes/builtin';
|
||||
import { getRouteName, getRoutePath } from '@/router/elegant/transform';
|
||||
import { getGlobalRouter } from '@/router/instance';
|
||||
import { useAuthStore } from '../auth';
|
||||
import { useDictStore } from '../dict';
|
||||
import { useTabStore } from '../tab';
|
||||
import {
|
||||
filterAuthRoutesByRoles,
|
||||
@@ -23,8 +22,27 @@ import {
|
||||
updateLocaleOfGlobalMenus
|
||||
} from './shared';
|
||||
|
||||
type RouteModule = typeof import('@/router/routes');
|
||||
|
||||
async function loadRouteModule(): Promise<RouteModule> {
|
||||
return import('@/router/routes');
|
||||
}
|
||||
|
||||
function createRootRoute(redirect: string): CustomRoute {
|
||||
return {
|
||||
name: 'root',
|
||||
path: '/',
|
||||
redirect,
|
||||
meta: {
|
||||
title: 'root',
|
||||
constant: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
const authStore = useAuthStore();
|
||||
const dictStore = useDictStore();
|
||||
const tabStore = useTabStore();
|
||||
const { bool: isInitConstantRoute, setBool: setIsInitConstantRoute } = useBoolean();
|
||||
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean();
|
||||
@@ -117,7 +135,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
* @param routeKey
|
||||
*/
|
||||
async function resetRouteCache(routeKey?: RouteKey) {
|
||||
const routeName = routeKey || (router.currentRoute.value.name as RouteKey);
|
||||
const routeName = routeKey || (getGlobalRouter().currentRoute.value.name as RouteKey);
|
||||
|
||||
excludeCacheRoutes.value.push(routeName);
|
||||
|
||||
@@ -127,13 +145,20 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/** 全局面包屑 */
|
||||
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value));
|
||||
const breadcrumbs = computed(() => getBreadcrumbsByRoute(getGlobalRouter().currentRoute.value, menus.value));
|
||||
|
||||
/** 重置 store */
|
||||
async function resetStore() {
|
||||
const routeStore = useRouteStore();
|
||||
|
||||
routeStore.$reset();
|
||||
// setup store 没有内置 $reset,需要显式重置内部状态。
|
||||
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true,导致下面 initConstantRoute 早返,
|
||||
// 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
|
||||
setIsInitConstantRoute(false);
|
||||
setIsInitAuthRoute(false);
|
||||
constantRoutes.value = [];
|
||||
authRoutes.value = [];
|
||||
menus.value = [];
|
||||
cacheRoutes.value = [];
|
||||
excludeCacheRoutes.value = [];
|
||||
|
||||
resetVueRoutes();
|
||||
|
||||
@@ -151,11 +176,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
async function initConstantRoute() {
|
||||
if (isInitConstantRoute.value) return;
|
||||
|
||||
const { createStaticRoutes } = await loadRouteModule();
|
||||
const staticRoute = createStaticRoutes();
|
||||
|
||||
addConstantRoutes(staticRoute.constantRoutes);
|
||||
|
||||
handleConstantAndAuthRoutes();
|
||||
await handleConstantAndAuthRoutes();
|
||||
|
||||
setIsInitConstantRoute(true);
|
||||
|
||||
@@ -169,8 +195,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
await authStore.initUserInfo();
|
||||
}
|
||||
|
||||
await dictStore.initDictCache();
|
||||
|
||||
if (authRouteMode.value === 'static') {
|
||||
initStaticAuthRoute();
|
||||
await initStaticAuthRoute();
|
||||
} else {
|
||||
await initDynamicAuthRoute();
|
||||
}
|
||||
@@ -179,7 +207,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/** 初始化静态权限路由 */
|
||||
function initStaticAuthRoute() {
|
||||
async function initStaticAuthRoute() {
|
||||
const { createStaticRoutes } = await loadRouteModule();
|
||||
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
|
||||
|
||||
if (authStore.isStaticSuper) {
|
||||
@@ -190,7 +219,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
addAuthRoutes(filteredAuthRoutes);
|
||||
}
|
||||
|
||||
handleConstantAndAuthRoutes();
|
||||
await handleConstantAndAuthRoutes();
|
||||
|
||||
setIsInitAuthRoute(true);
|
||||
}
|
||||
@@ -204,11 +233,11 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
|
||||
addAuthRoutes(routes);
|
||||
|
||||
handleConstantAndAuthRoutes();
|
||||
await handleConstantAndAuthRoutes();
|
||||
|
||||
setRouteHome(home);
|
||||
|
||||
handleUpdateRootRouteRedirect(home);
|
||||
await handleUpdateRootRouteRedirect(home);
|
||||
|
||||
setIsInitAuthRoute(true);
|
||||
} else {
|
||||
@@ -218,8 +247,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/** 统一处理常量路由和权限路由 */
|
||||
function handleConstantAndAuthRoutes() {
|
||||
const allRoutes = [...constantRoutes.value, ...authRoutes.value];
|
||||
async function handleConstantAndAuthRoutes() {
|
||||
const { getAuthVueRoutes } = await loadRouteModule();
|
||||
// 常量路由优先:动态权限路由中与常量路由 name 重复的项剔除,避免菜单出现重复入口(如 workbench)
|
||||
const constantRouteNames = new Set(constantRoutes.value.map(route => route.name));
|
||||
const dedupedAuthRoutes = authRoutes.value.filter(route => !constantRouteNames.has(route.name));
|
||||
const allRoutes = [...constantRoutes.value, ...dedupedAuthRoutes];
|
||||
|
||||
const sortRoutes = sortRoutesByOrder(allRoutes);
|
||||
|
||||
@@ -241,7 +274,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
*/
|
||||
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
|
||||
routes.forEach(route => {
|
||||
const removeFn = router.addRoute(route);
|
||||
const removeFn = getGlobalRouter().addRoute(route);
|
||||
addRemoveRouteFn(removeFn);
|
||||
});
|
||||
}
|
||||
@@ -260,11 +293,13 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
*
|
||||
* @param redirectKey 重定向目标路由 key
|
||||
*/
|
||||
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
|
||||
async function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
|
||||
const redirect = getRoutePath(redirectKey);
|
||||
|
||||
if (redirect) {
|
||||
const rootRoute: CustomRoute = { ...ROOT_ROUTE, redirect };
|
||||
const { getAuthVueRoutes } = await loadRouteModule();
|
||||
const rootRoute = createRootRoute(redirect);
|
||||
const router = getGlobalRouter();
|
||||
|
||||
router.removeRoute(rootRoute.name);
|
||||
|
||||
@@ -287,6 +322,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
if (authRouteMode.value === 'static') {
|
||||
const { createStaticRoutes } = await loadRouteModule();
|
||||
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
|
||||
return isRouteExistByRouteName(routeName, staticAuthRoutes);
|
||||
}
|
||||
@@ -316,6 +352,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
return {
|
||||
resetStore,
|
||||
routeHome,
|
||||
authRoutes,
|
||||
menus,
|
||||
searchMenus,
|
||||
updateGlobalMenusByLocale,
|
||||
|
||||
@@ -153,7 +153,12 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
||||
const cacheNames: LastLevelRouteKey[] = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
// only get last two level route, which has component
|
||||
// Check first-level routes (routes with component but no children)
|
||||
if (route.component && route.meta?.keepAlive && !route.children?.length) {
|
||||
cacheNames.push(route.name as LastLevelRouteKey);
|
||||
}
|
||||
|
||||
// Check second-level routes
|
||||
route.children?.forEach(child => {
|
||||
if (child.component && child.meta?.keepAlive) {
|
||||
cacheNames.push(child.name as LastLevelRouteKey);
|
||||
|
||||
@@ -2,10 +2,10 @@ import { computed, ref } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { router } from '@/router';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { getGlobalRouter } from '@/router/instance';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { useThemeStore } from '../theme';
|
||||
import {
|
||||
@@ -35,7 +35,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
|
||||
|
||||
/** Init home tab */
|
||||
function initHomeTab() {
|
||||
homeTab.value = getDefaultHomeTab(router, routeStore.routeHome);
|
||||
homeTab.value = getDefaultHomeTab(getGlobalRouter(), routeStore.routeHome);
|
||||
}
|
||||
|
||||
/** Get all tabs */
|
||||
@@ -62,7 +62,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
|
||||
const storageTabs = localStg.get('globalTabs');
|
||||
|
||||
if (themeStore.tab.cache && storageTabs) {
|
||||
const extractedTabs = extractTabsByAllRoutes(router, storageTabs);
|
||||
const extractedTabs = extractTabsByAllRoutes(getGlobalRouter(), storageTabs);
|
||||
tabs.value = updateTabsByI18nKey(extractedTabs);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { usePreferredColorScheme } from '@vueuse/core';
|
||||
import { breakpointsTailwind, useBreakpoints, usePreferredColorScheme } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { getPaletteColorByNumber } from '@sa/color';
|
||||
import { localStg } from '@/utils/storage';
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
const scope = effectScope();
|
||||
const osTheme = usePreferredColorScheme();
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const isMobile = breakpoints.smaller('sm');
|
||||
|
||||
/** Theme settings */
|
||||
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
|
||||
@@ -51,6 +53,12 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
/** UI theme */
|
||||
const uiTheme = computed(() => getNaiveTheme(themeColors.value, settings.value.recommendColor));
|
||||
|
||||
/** Product layout mode */
|
||||
const layoutMode = computed<UnionKey.ThemeLayoutMode>(() => (isMobile.value ? 'vertical' : 'horizontal-mix'));
|
||||
|
||||
/** Product tab visible */
|
||||
const tabVisible = computed(() => (isMobile.value ? settings.value.tab.visible : false));
|
||||
|
||||
/**
|
||||
* Settings json
|
||||
*
|
||||
@@ -216,6 +224,8 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
|
||||
darkMode,
|
||||
themeColors,
|
||||
uiTheme,
|
||||
layoutMode,
|
||||
tabVisible,
|
||||
settingsJson,
|
||||
setGrayscale,
|
||||
setColourWeakness,
|
||||
|
||||
11
src/store/modules/workbench/index.ts
Normal file
11
src/store/modules/workbench/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useWorkbenchLayout } from '@/views/workbench/composables/use-workbench-layout';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
export const useWorkbenchStore = defineStore(SetupStoreId.Workbench, () => {
|
||||
const authStore = useAuthStore();
|
||||
const userId = computed(() => String(authStore.userInfo?.userId ?? 'anonymous'));
|
||||
return useWorkbenchLayout({ userId: userId.value });
|
||||
});
|
||||
@@ -416,6 +416,20 @@ html .el-collapse {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.business-table-action-icon-button {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
&.el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.business-table-action-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.business-table-action-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -428,6 +442,31 @@ html .el-collapse {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.business-table-action-menu__link {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0 !important;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.business-table-action-menu__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.business-table-card-body {
|
||||
display: flex;
|
||||
height: calc(100% - 56px);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
> .flex-1 {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.el-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -484,3 +523,44 @@ html .el-collapse {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.el-message {
|
||||
min-width: 280px;
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
box-shadow: 0 6px 16px rgb(0 0 0 / 15%);
|
||||
|
||||
.el-message__content {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.el-message__closeBtn {
|
||||
color: rgb(255 255 255 / 80%);
|
||||
}
|
||||
|
||||
.el-message__closeBtn:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: var(--el-color-info);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background-color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
},
|
||||
isInfoFollowPrimary: true,
|
||||
layout: {
|
||||
mode: 'vertical',
|
||||
mode: 'horizontal-mix',
|
||||
scrollMode: 'content',
|
||||
reverseHorizontalMix: false
|
||||
},
|
||||
|
||||
35
src/typings/api/auth.d.ts
vendored
35
src/typings/api/auth.d.ts
vendored
@@ -13,8 +13,43 @@ declare namespace Api {
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
nickname: string;
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
}
|
||||
|
||||
interface MyProfileDetail {
|
||||
userId: string;
|
||||
username: string;
|
||||
nickname?: string | null;
|
||||
deptId?: string | null;
|
||||
deptName?: string | null;
|
||||
positionId?: string | null;
|
||||
positionName?: string | null;
|
||||
company?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
roles: Api.SystemManage.RoleSimple[];
|
||||
dept?: Api.SystemManage.DeptSimple | null;
|
||||
position?: Api.SystemManage.PostSimple | null;
|
||||
loginIp?: string | null;
|
||||
loginDate?: string | null;
|
||||
createTime?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateMyProfileParams {
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
sex?: Api.SystemManage.UserGender | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateMyPasswordParams {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user