feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting) - 实现产品基础信息编辑弹窗组件(base-info-dialog.vue) - 添加运行时字典功能(dict-select、dict-text、dict-tag组件) - 集成字典管理store和API调用 - 规范ID类型定义为string避免精度丢失问题 - 完善国际化资源文件支持中英文对照 - 新增对象上下文业务域入口页导航实现说明 - 添加Vue DevTools浮动入口注释说明 - 统一权限控制支持全局和对象作用域区分 - 规范分页查询参数类型定义与使用方式
This commit is contained in:
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,3 +37,6 @@ yarn.lock
|
||||
# Docs
|
||||
/docs/*
|
||||
!/docs/frontend-page-resource-manifest.json
|
||||
|
||||
# Temp
|
||||
/codeTemp/*
|
||||
|
||||
52
AGENTS.md
52
AGENTS.md
@@ -85,6 +85,15 @@
|
||||
- 常量路由维护入口优先是 `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`
|
||||
@@ -158,6 +167,40 @@
|
||||
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*` 和相关文档。
|
||||
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
|
||||
|
||||
## 运行时字典使用口径
|
||||
|
||||
- 运行时字典统一由 `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`,不要前端自己命名。
|
||||
|
||||
## 页面资源与菜单目录约束
|
||||
|
||||
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
||||
@@ -170,6 +213,15 @@
|
||||
|
||||
- 优先使用现有别名导入(`@/...`、`~/...`),避免过长的相对路径。
|
||||
- 保持与 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 用法。
|
||||
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID,当前任务触达相关链路时优先顺手矫正”;不要继续复制历史写法。
|
||||
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||
- 修改界面时优先延续 `src/layouts` 和 `src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
|
||||
- 注释保持克制,只在代码本身不够直观时补充必要说明。
|
||||
|
||||
@@ -29,6 +29,27 @@ export function setupElegantRouter() {
|
||||
|
||||
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
|
||||
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
|
||||
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'
|
||||
},
|
||||
system: {
|
||||
icon: 'carbon:cloud-service-management',
|
||||
order: 9,
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
{
|
||||
"generatedAt": "2026-03-27T05:39:32.467Z",
|
||||
"generatedAt": "2026-04-20T11:27:02.190Z",
|
||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
"pageComponentPattern": "view.<routeName>",
|
||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||
},
|
||||
"total": 6,
|
||||
"total": 7,
|
||||
"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": "system_user",
|
||||
"path": "/system/user",
|
||||
@@ -174,12 +207,12 @@
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "system_user_management_relation",
|
||||
"name": "system_user-management-relation",
|
||||
"path": "/system/user-management-relation",
|
||||
"component": "view.system_user-management-relation",
|
||||
"title": "管理链路",
|
||||
"routeTitle": "system-user-management-relation",
|
||||
"i18nKey": "",
|
||||
"routeTitle": "system_user-management-relation",
|
||||
"i18nKey": "route.system_user-management-relation",
|
||||
"icon": null,
|
||||
"localIcon": null,
|
||||
"order": null,
|
||||
@@ -192,7 +225,7 @@
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "管理链路",
|
||||
"i18nKey": "",
|
||||
"i18nKey": "route.system_user-management-relation",
|
||||
"icon": null,
|
||||
"localIcon": null,
|
||||
"order": null,
|
||||
|
||||
393
route.json
393
route.json
@@ -1,393 +0,0 @@
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"routes": [
|
||||
{
|
||||
"id": "900000",
|
||||
"name": "system",
|
||||
"path": "/system",
|
||||
"component": "layout.base",
|
||||
"redirect": "/system/user",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "权限中心",
|
||||
"i18nKey": null,
|
||||
"icon": "carbon:cloud-service-management",
|
||||
"localIcon": null,
|
||||
"order": 9,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "900001",
|
||||
"name": "system_user",
|
||||
"path": "/system/user",
|
||||
"component": "view.system_user",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "用户管理",
|
||||
"i18nKey": null,
|
||||
"icon": "ic:round-manage-accounts",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "900002",
|
||||
"name": "system_role",
|
||||
"path": "/system/role",
|
||||
"component": "view.system_role",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "角色管理",
|
||||
"i18nKey": null,
|
||||
"icon": "carbon:user-role",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "2036723586735665153",
|
||||
"name": "system_post",
|
||||
"path": "/system/post",
|
||||
"component": "view.system_post",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "岗位管理",
|
||||
"i18nKey": null,
|
||||
"icon": "mdi:account-group-outline",
|
||||
"localIcon": null,
|
||||
"order": 3,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "900003",
|
||||
"name": "system_menu",
|
||||
"path": "/system/menu",
|
||||
"component": "view.system_menu",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "菜单管理",
|
||||
"i18nKey": null,
|
||||
"icon": "material-symbols:route",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"keepAlive": false,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"name": "legacy-system",
|
||||
"path": "/legacy-system",
|
||||
"component": "layout.base",
|
||||
"redirect": "/legacy-system/dict",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "系统管理",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:tools",
|
||||
"localIcon": null,
|
||||
"order": 10,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "900004",
|
||||
"name": "system_dict",
|
||||
"path": "/legacy-system/dict",
|
||||
"component": "view.system_dict",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "字典管理",
|
||||
"i18nKey": null,
|
||||
"icon": "mdi:book-open-page-variant-outline",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "108",
|
||||
"name": "legacy_system_log",
|
||||
"path": "/legacy-system/log",
|
||||
"component": "layout.base",
|
||||
"redirect": "/legacy-system/log/operate-log",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "审计日志",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:document-copy",
|
||||
"localIcon": null,
|
||||
"order": 9,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "500",
|
||||
"name": "SystemOperateLog",
|
||||
"path": "/legacy-system/log/operate-log",
|
||||
"component": "system/operatelog/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "操作日志",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:position",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "501",
|
||||
"name": "SystemLoginLog",
|
||||
"path": "/legacy-system/log/login-log",
|
||||
"component": "system/loginlog/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "登录日志",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:promotion",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "infra",
|
||||
"path": "/infra",
|
||||
"component": "layout.base",
|
||||
"redirect": "/infra/log",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "基础设施",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:monitor",
|
||||
"localIcon": null,
|
||||
"order": 20,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "1083",
|
||||
"name": "infra_log",
|
||||
"path": "/infra/log",
|
||||
"component": "layout.base",
|
||||
"redirect": "/infra/log/api-access-log",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "API 日志",
|
||||
"i18nKey": null,
|
||||
"icon": "fa:tasks",
|
||||
"localIcon": null,
|
||||
"order": 4,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "1078",
|
||||
"name": "InfraApiAccessLog",
|
||||
"path": "/infra/log/api-access-log",
|
||||
"component": "infra/apiAccessLog/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "访问日志",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:place",
|
||||
"localIcon": null,
|
||||
"order": 1,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "1084",
|
||||
"name": "InfraApiErrorLog",
|
||||
"path": "/infra/log/api-error-log",
|
||||
"component": "infra/apiErrorLog/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "错误日志",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:warning-filled",
|
||||
"localIcon": null,
|
||||
"order": 2,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1243",
|
||||
"name": "infra_file",
|
||||
"path": "/infra/file",
|
||||
"component": "layout.base",
|
||||
"redirect": "/infra/file/file-config",
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "文件管理",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:files",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "1237",
|
||||
"name": "InfraFileConfig",
|
||||
"path": "/infra/file/file-config",
|
||||
"component": "infra/fileConfig/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "文件配置",
|
||||
"i18nKey": null,
|
||||
"icon": "fa-solid:file-signature",
|
||||
"localIcon": null,
|
||||
"order": 0,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
},
|
||||
{
|
||||
"id": "1090",
|
||||
"name": "InfraFile",
|
||||
"path": "/infra/file/file",
|
||||
"component": "infra/file/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "文件列表",
|
||||
"i18nKey": null,
|
||||
"icon": "ep:upload-filled",
|
||||
"localIcon": null,
|
||||
"order": 5,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "106",
|
||||
"name": "InfraConfig",
|
||||
"path": "/infra/config",
|
||||
"component": "infra/config/index",
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "配置管理",
|
||||
"i18nKey": null,
|
||||
"icon": "fa:connectdevelop",
|
||||
"localIcon": null,
|
||||
"order": 8,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": null,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"children": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"home": "system_user"
|
||||
}
|
||||
}
|
||||
62
src/components/custom/dict-select.vue
Normal file
62
src/components/custom/dict-select.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
onlyEnabled?: boolean;
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
filterable: false,
|
||||
onlyEnabled: true,
|
||||
multiple: false,
|
||||
collapseTags: false,
|
||||
collapseTagsTooltip: false
|
||||
});
|
||||
|
||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||
default: undefined
|
||||
});
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="model"
|
||||
class="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"
|
||||
>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
48
src/components/custom/dict-tag.vue
Normal file
48
src/components/custom/dict-tag.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
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
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
|
||||
<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>
|
||||
@@ -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'
|
||||
|
||||
37
src/constants/dict.ts
Normal file
37
src/constants/dict.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 运行时字典编码常量
|
||||
*
|
||||
* 约定:
|
||||
* 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';
|
||||
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_dashboard',
|
||||
fallbackDefaultRoutePath: '/project/dashboard',
|
||||
contextApiPath: `${WEB_SERVICE_PREFIX}/project/context`,
|
||||
contextApiObjectIdParamKey: 'projectId',
|
||||
contextApiObjectIdPlacement: 'query',
|
||||
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];
|
||||
}
|
||||
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,8 @@ 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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,17 +2,31 @@ 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;
|
||||
}
|
||||
@@ -20,10 +34,16 @@ function useMixMenu() {
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelMenuKey = ''] = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelMenuKey);
|
||||
}
|
||||
if (firstLevelMenuKey) {
|
||||
setActiveFirstLevelMenuKey(firstLevelMenuKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
const fallbackFirstLevelMenuKey =
|
||||
allMenus.value.find(menu => isMenuMatchedByPath(route.path, menu.routePath))?.key || '';
|
||||
|
||||
setActiveFirstLevelMenuKey(fallbackFirstLevelMenuKey);
|
||||
}
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
@@ -37,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;
|
||||
@@ -48,7 +87,7 @@ function useMixMenu() {
|
||||
});
|
||||
|
||||
watch(
|
||||
[selectedKey, allMenus],
|
||||
[selectedKey, allMenus, () => route.path],
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
@@ -59,6 +98,9 @@ function useMixMenu() {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
headerMenuMode,
|
||||
headerMenus,
|
||||
currentObjectContextDomain,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
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 { useRouteStore } from '@/store/modules/route';
|
||||
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';
|
||||
@@ -13,29 +15,66 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
const route = useRoute();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
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 selectedMenuKeyPath = computed(() => routeStore.getSelectedMenuKeyPath(selectedKey.value));
|
||||
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);
|
||||
|
||||
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
|
||||
|
||||
if (domainConfig) {
|
||||
objectContextStore.clearContext();
|
||||
routerPush({ path: domainConfig.entryRoutePath });
|
||||
return;
|
||||
}
|
||||
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
|
||||
function handleClickNavItem(menu: App.Global.Menu) {
|
||||
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;
|
||||
}
|
||||
@@ -43,8 +82,12 @@ function handleClickDomainAnchor() {
|
||||
routerPushByKeyWithMetaQuery(activeFirstLevelMenu.value.routeKey);
|
||||
}
|
||||
|
||||
function isMenuActive(menu: App.Global.Menu) {
|
||||
return selectedMenuKeyPath.value.includes(menu.key);
|
||||
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>
|
||||
|
||||
@@ -60,9 +103,19 @@ function isMenuActive(menu: App.Global.Menu) {
|
||||
<component :is="activeFirstLevelMenu.icon" v-if="activeFirstLevelMenu.icon" class="text-icon" />
|
||||
<span class="domain-anchor__label">{{ activeFirstLevelMenu.label }}</span>
|
||||
</button>
|
||||
<div v-if="childLevelMenus.length" class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"></div>
|
||||
<div v-if="childLevelMenus.length" class="header-nav-list h-full min-w-0 flex-1">
|
||||
<template v-for="item in childLevelMenus" :key="item.key">
|
||||
<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">
|
||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
||||
</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"
|
||||
@@ -155,6 +208,28 @@ function isMenuActive(menu: App.Global.Menu) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-object-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-object-tag__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 14rem;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -169,6 +169,11 @@ const local: App.I18n.Schema = {
|
||||
function_request: 'Request',
|
||||
'function_toggle-auth': 'Toggle Auth',
|
||||
'function_super-page': 'Super Admin Visible',
|
||||
product: 'Product Management',
|
||||
product_list: 'Product List',
|
||||
product_dashboard: 'Product Dashboard',
|
||||
product_requirement: 'Requirement Pool',
|
||||
product_setting: 'Product Settings',
|
||||
system: 'System Management',
|
||||
system_user: 'User Management',
|
||||
'system_user-detail': 'User Detail',
|
||||
@@ -344,6 +349,14 @@ const local: App.I18n.Schema = {
|
||||
status: {
|
||||
enable: 'Enable',
|
||||
disable: 'Disable'
|
||||
},
|
||||
scopeType: {
|
||||
global: 'Global',
|
||||
object: 'Object Scope'
|
||||
},
|
||||
objectType: {
|
||||
product: 'Product',
|
||||
project: 'Project'
|
||||
}
|
||||
},
|
||||
role: {
|
||||
@@ -367,6 +380,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: {
|
||||
@@ -492,6 +515,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',
|
||||
@@ -521,6 +545,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',
|
||||
@@ -532,6 +590,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',
|
||||
@@ -539,6 +598,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"}',
|
||||
@@ -586,6 +646,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.'
|
||||
},
|
||||
@@ -595,7 +657,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',
|
||||
|
||||
@@ -169,6 +169,11 @@ const local: App.I18n.Schema = {
|
||||
function_request: '请求',
|
||||
'function_toggle-auth': '切换权限',
|
||||
'function_super-page': '超级管理员可见',
|
||||
product: '产品管理',
|
||||
product_list: '产品列表',
|
||||
product_dashboard: '产品仪表盘',
|
||||
product_requirement: '需求池',
|
||||
product_setting: '产品设置',
|
||||
system: '系统管理',
|
||||
system_user: '用户管理',
|
||||
'system_user-detail': '用户详情',
|
||||
@@ -343,6 +348,14 @@ const local: App.I18n.Schema = {
|
||||
status: {
|
||||
enable: '启用',
|
||||
disable: '禁用'
|
||||
},
|
||||
scopeType: {
|
||||
global: '全域',
|
||||
object: '对象域'
|
||||
},
|
||||
objectType: {
|
||||
product: '产品',
|
||||
project: '项目'
|
||||
}
|
||||
},
|
||||
role: {
|
||||
@@ -358,7 +371,7 @@ const local: App.I18n.Schema = {
|
||||
roleStatus: '角色状态',
|
||||
roleDesc: '角色描述',
|
||||
remark: '备注',
|
||||
sort: '显示顺序',
|
||||
sort: '排序',
|
||||
createTime: '创建时间',
|
||||
menuAuth: '菜单权限',
|
||||
buttonAuth: '按钮权限',
|
||||
@@ -366,6 +379,13 @@ const local: App.I18n.Schema = {
|
||||
selectedCount: '已选资源',
|
||||
disabledTip: '禁用角色不允许分配菜单权限',
|
||||
emptyRole: '请先选择角色',
|
||||
currentRoleCount: '当前角色数',
|
||||
globalRoleTitle: '全域角色',
|
||||
objectRoleTitle: '对象域角色模板',
|
||||
globalRoleSummary: '当前维护登录态全域角色及其资源授权关系。',
|
||||
objectRoleSummary: '当前维护对象域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
objectRoleSummaryProduct: '当前维护产品域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
objectRoleSummaryProject: '当前维护项目域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
|
||||
lastAuthSave: '最近一次授权保存',
|
||||
unsavedTip: '授权变更后请记得保存',
|
||||
form: {
|
||||
@@ -374,7 +394,7 @@ const local: App.I18n.Schema = {
|
||||
roleStatus: '请选择角色状态',
|
||||
roleDesc: '请输入角色描述',
|
||||
remark: '请输入备注',
|
||||
sort: '请输入显示顺序',
|
||||
sort: '请输入排序',
|
||||
resourceKeyword: '输入资源名称过滤权限树',
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
@@ -491,6 +511,7 @@ const local: App.I18n.Schema = {
|
||||
routeKind: '路由类型',
|
||||
routePropsJson: '路由参数 JSON',
|
||||
pageResource: '页面资源',
|
||||
boundRoute: '绑定路由',
|
||||
component: '组件路径',
|
||||
componentName: '组件名称',
|
||||
iframeUrl: 'iframe 地址',
|
||||
@@ -520,6 +541,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: '路由信息',
|
||||
@@ -531,6 +582,7 @@ const local: App.I18n.Schema = {
|
||||
parentId: '请选择父级菜单',
|
||||
menuName: '请输入菜单名称',
|
||||
permission: '请输入权限标识',
|
||||
resourceCode: '请输入资源编码',
|
||||
routeName: '请输入路由名称',
|
||||
routePath: '请输入路由路径',
|
||||
path: '请输入路由地址',
|
||||
@@ -538,6 +590,7 @@ const local: App.I18n.Schema = {
|
||||
componentName: '请输入组件名称',
|
||||
routeKind: '请选择路由类型',
|
||||
pageResource: '请选择页面资源',
|
||||
boundRoute: '请选择绑定路由',
|
||||
pageResourceParentMismatch: '所选页面资源与当前父级菜单层级不匹配',
|
||||
routePropsJson: '请输入合法的 JSON 字符串',
|
||||
routePropsJsonHint: '例如 {"url":"https://example.com"}',
|
||||
@@ -581,6 +634,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。'
|
||||
},
|
||||
@@ -590,7 +645,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);
|
||||
|
||||
@@ -47,6 +47,10 @@ 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"),
|
||||
system_dict: () => import("@/views/system/dict/index.vue"),
|
||||
system_menu: () => import("@/views/system/menu/index.vue"),
|
||||
system_post: () => import("@/views/system/post/index.vue"),
|
||||
|
||||
@@ -430,6 +430,64 @@ 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: 'system',
|
||||
path: '/system',
|
||||
|
||||
@@ -206,6 +206,11 @@ 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",
|
||||
"system": "/system",
|
||||
"system_dict": "/system/dict",
|
||||
"system_menu": "/system/menu",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './auth';
|
||||
export * from './dict';
|
||||
export * from './object-context';
|
||||
export * from './product';
|
||||
export * from './route';
|
||||
export * from './system-manage';
|
||||
|
||||
202
src/service/api/object-context.ts
Normal file
202
src/service/api/object-context.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { LocationQueryValue } from 'vue-router';
|
||||
import { request } from '../request';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
|
||||
interface BackendObjectContextMenuDTO {
|
||||
key?: string | null;
|
||||
label?: string | null;
|
||||
routeKey?: string | null;
|
||||
routePath?: string | null;
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
path?: string | 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 BackendProductContextRoleDTO {
|
||||
roleId?: string | number | null;
|
||||
roleCode?: string | null;
|
||||
roleName?: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
currentRole?: BackendProductContextRoleDTO | 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 normalizeCurrentRole(role: BackendProductContextRoleDTO) {
|
||||
return {
|
||||
roleId: normalizeStringId(role.roleId || ''),
|
||||
roleCode: normalizeString(role.roleCode),
|
||||
roleName: normalizeString(role.roleName)
|
||||
};
|
||||
}
|
||||
|
||||
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 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.currentRole !== undefined) {
|
||||
summary.currentRole = data.currentRole ? normalizeCurrentRole(data.currentRole) : null;
|
||||
}
|
||||
|
||||
return Object.keys(summary).length ? summary : null;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function normalizeObjectContext(
|
||||
config: App.ObjectContext.DomainConfig,
|
||||
objectId: string,
|
||||
data: BackendObjectContextDTO
|
||||
): Api.ObjectContext.ContextInfo {
|
||||
const rawMenus = data.contextScopedMenus ?? data.menus ?? data.navs ?? [];
|
||||
const contextScopedMenus = rawMenus.map(menu => normalizeMenu(menu));
|
||||
const firstRoutableMenu = getFirstRoutableMenu(contextScopedMenus);
|
||||
const currentProduct = data.currentProduct ? normalizeCurrentProduct(data.currentProduct) : 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 || objectId,
|
||||
objectName: normalizeString(data.objectName || currentProduct?.name),
|
||||
objectSummary: normalizeObjectSummary(data),
|
||||
contextScopedMenus,
|
||||
buttonCodes: data.buttonCodes ?? data.buttons ?? [],
|
||||
defaultRouteKey: data.defaultRouteKey || firstRoutableMenu?.routeKey || '',
|
||||
defaultRoutePath:
|
||||
normalizeRoutePath(data.defaultRoutePath) || firstRoutableMenu?.routePath || config.fallbackDefaultRoutePath
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
157
src/service/api/product.ts
Normal file
157
src/service/api/product.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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>;
|
||||
|
||||
function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
return {
|
||||
...product,
|
||||
id: normalizeStringId(product.id),
|
||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
/** 鑾峰彇浜у搧鍒嗛〉 */
|
||||
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 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 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
|
||||
});
|
||||
}
|
||||
|
||||
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 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 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 fetchInactiveProductMember(
|
||||
id: string,
|
||||
memberId: string,
|
||||
data: Api.Product.InactiveProductMemberParams
|
||||
) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PRODUCT_PREFIX}/${id}/members/${memberId}/inactive`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { LastLevelRouteKey } from '@elegant-router/types';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
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 +18,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 +36,117 @@ 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;
|
||||
}
|
||||
|
||||
const wrappedDomainRoute = cloneStaticRouteAsMenuRoute(staticDomainRoute, `object-context:${config.domainKey}`);
|
||||
const entryRouteIndex = normalizedRoutes.findIndex(route => route.id === entryRoute.id);
|
||||
const domainRouteIds = new Set(domainTopLevelRoutes.map(route => route.id));
|
||||
|
||||
if (entryRoute.meta) {
|
||||
const nextMeta: RouteMeta = {
|
||||
title: wrappedDomainRoute.meta?.title || config.domainKey,
|
||||
...(wrappedDomainRoute.meta || {})
|
||||
};
|
||||
|
||||
if (entryRoute.meta.icon) {
|
||||
nextMeta.icon = entryRoute.meta.icon;
|
||||
}
|
||||
|
||||
if (entryRoute.meta.localIcon) {
|
||||
nextMeta.localIcon = entryRoute.meta.localIcon;
|
||||
}
|
||||
|
||||
if (entryRoute.meta.order !== undefined) {
|
||||
nextMeta.order = entryRoute.meta.order;
|
||||
}
|
||||
|
||||
wrappedDomainRoute.meta = nextMeta;
|
||||
}
|
||||
|
||||
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,12 @@
|
||||
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`;
|
||||
@@ -45,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 => {
|
||||
@@ -58,39 +72,160 @@ function createBatchDeleteQuery(ids: number[]) {
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -99,7 +234,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',
|
||||
@@ -108,7 +243,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'
|
||||
@@ -121,7 +256,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'
|
||||
});
|
||||
@@ -132,20 +268,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));
|
||||
}
|
||||
|
||||
/** 获取部门列表 */
|
||||
@@ -300,10 +444,13 @@ export function fetchBatchDeletePost(ids: number[]) {
|
||||
|
||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||
export function fetchGetUserSimpleList() {
|
||||
return request<Api.SystemManage.UserSimple[]>({
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/simple-list`,
|
||||
method: 'get'
|
||||
});
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取用户分页 */
|
||||
@@ -320,11 +467,14 @@ export const fetchGetUserList = fetchGetUserPage;
|
||||
|
||||
/** 通过部门id获取用户详情 */
|
||||
export function fetchGetUserListByDeptId(deptId: any) {
|
||||
return request<Api.SystemManage.UserSimple[]>({
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取用户详情 */
|
||||
@@ -390,34 +540,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',
|
||||
@@ -426,7 +587,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',
|
||||
@@ -435,7 +596,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'
|
||||
@@ -443,20 +604,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));
|
||||
}
|
||||
|
||||
/** 分配角色菜单 */
|
||||
@@ -469,12 +637,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));
|
||||
}
|
||||
|
||||
/** 分配用户角色 */
|
||||
@@ -497,11 +668,16 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
|
||||
* - 叶子节点:基层员工,没有下级
|
||||
*/
|
||||
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
||||
return request<Api.SystemManage.UserManagementRelationTreeRespVO[]>({
|
||||
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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,11 +685,16 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
|
||||
* 用于树形控件展示,包含用户的上下级层级关系
|
||||
*/
|
||||
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
||||
return request<Api.SystemManage.UserManagementRelationTreeRespVO[]>({
|
||||
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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,12 +704,15 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
|
||||
*
|
||||
* @param id 关系记录主键 ID
|
||||
*/
|
||||
export function fetchGetUserManagementRelation(id: number) {
|
||||
return request<Api.SystemManage.UserManagementRelation>({
|
||||
export 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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,11 +723,12 @@ export function fetchGetUserManagementRelation(id: number) {
|
||||
* @param data 创建请求参数
|
||||
*/
|
||||
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
||||
return request<number>({
|
||||
return request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}).then(result => mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -554,7 +739,7 @@ export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserMan
|
||||
* @param data 更新请求参数(包含 id)
|
||||
*/
|
||||
export function fetchUpdateUserManagementRelation(
|
||||
data: { id: number } & Api.SystemManage.UserManagementRelationSaveReqVO
|
||||
data: { id: string } & Api.SystemManage.UserManagementRelationSaveReqVO
|
||||
) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/update`,
|
||||
@@ -570,7 +755,7 @@ export function fetchUpdateUserManagementRelation(
|
||||
*
|
||||
* @param id 关系记录主键 ID
|
||||
*/
|
||||
export function fetchDeleteUserManagementRelation(id: number | null) {
|
||||
export function fetchDeleteUserManagementRelation(id: string | null) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete`,
|
||||
method: 'delete',
|
||||
@@ -585,7 +770,7 @@ export function fetchDeleteUserManagementRelation(id: number | null) {
|
||||
*
|
||||
* @param ids 关系记录主键 ID 列表
|
||||
*/
|
||||
export function fetchBatchDeleteUserManagementRelation(ids: number[]) {
|
||||
export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
|
||||
return request<boolean>({
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||
method: 'delete'
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ 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 +18,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();
|
||||
|
||||
@@ -46,6 +50,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
clearAuthStorage();
|
||||
|
||||
authStore.$reset();
|
||||
dictStore.resetDictCache();
|
||||
objectContextStore.$reset();
|
||||
|
||||
if (!route.meta.constant) {
|
||||
await toLogin();
|
||||
@@ -138,6 +144,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const pass = await getUserInfo();
|
||||
|
||||
if (pass) {
|
||||
await dictStore.initDictCache(true);
|
||||
|
||||
token.value = loginToken.token;
|
||||
|
||||
return true;
|
||||
|
||||
210
src/store/modules/dict/index.ts
Normal file
210
src/store/modules/dict/index.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||
import { 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'));
|
||||
}
|
||||
|
||||
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,
|
||||
remark: null,
|
||||
createTime: 0
|
||||
}));
|
||||
|
||||
return sortDictData(normalizedList);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
function resetDictCache() {
|
||||
dictTypes.value = [];
|
||||
dictDataMap.value = {};
|
||||
loadedAt.value = null;
|
||||
initialized.value = false;
|
||||
initPromise = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
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,7 +145,7 @@ 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() {
|
||||
@@ -151,11 +169,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 +188,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 +200,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 +212,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
addAuthRoutes(filteredAuthRoutes);
|
||||
}
|
||||
|
||||
handleConstantAndAuthRoutes();
|
||||
await handleConstantAndAuthRoutes();
|
||||
|
||||
setIsInitAuthRoute(true);
|
||||
}
|
||||
@@ -204,11 +226,11 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
|
||||
addAuthRoutes(routes);
|
||||
|
||||
handleConstantAndAuthRoutes();
|
||||
await handleConstantAndAuthRoutes();
|
||||
|
||||
setRouteHome(home);
|
||||
|
||||
handleUpdateRootRouteRedirect(home);
|
||||
await handleUpdateRootRouteRedirect(home);
|
||||
|
||||
setIsInitAuthRoute(true);
|
||||
} else {
|
||||
@@ -218,7 +240,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
/** 统一处理常量路由和权限路由 */
|
||||
function handleConstantAndAuthRoutes() {
|
||||
async function handleConstantAndAuthRoutes() {
|
||||
const { getAuthVueRoutes } = await loadRouteModule();
|
||||
const allRoutes = [...constantRoutes.value, ...authRoutes.value];
|
||||
|
||||
const sortRoutes = sortRoutesByOrder(allRoutes);
|
||||
@@ -241,7 +264,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 +283,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 +312,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 +342,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
return {
|
||||
resetStore,
|
||||
routeHome,
|
||||
authRoutes,
|
||||
menus,
|
||||
searchMenus,
|
||||
updateGlobalMenusByLocale,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
17
src/typings/api/dict.d.ts
vendored
17
src/typings/api/dict.d.ts
vendored
@@ -61,6 +61,23 @@ declare namespace Api {
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
/** frontend runtime dict item */
|
||||
interface FrontendDictData {
|
||||
/** dict label */
|
||||
label: string;
|
||||
/** dict value */
|
||||
value: string;
|
||||
/** display order */
|
||||
sort: number;
|
||||
/** dict type code */
|
||||
dictType?: string;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
status?: DictStatus;
|
||||
}
|
||||
|
||||
/** frontend runtime dict cache map */
|
||||
type FrontendDictCache = Record<string, FrontendDictData[]>;
|
||||
|
||||
/** dict data search params */
|
||||
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||
|
||||
|
||||
15
src/typings/api/object-context.d.ts
vendored
Normal file
15
src/typings/api/object-context.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare namespace Api {
|
||||
namespace ObjectContext {
|
||||
interface ContextInfo {
|
||||
domainKey: App.ObjectContext.DomainKey;
|
||||
objectType: App.ObjectContext.ObjectType;
|
||||
objectId: string;
|
||||
objectName: string;
|
||||
objectSummary: App.ObjectContext.Summary | null;
|
||||
contextScopedMenus: App.ObjectContext.Menu[];
|
||||
buttonCodes: string[];
|
||||
defaultRouteKey: string;
|
||||
defaultRoutePath: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/typings/api/product.d.ts
vendored
Normal file
164
src/typings/api/product.d.ts
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace Product
|
||||
*
|
||||
* backend api module: "project/product"
|
||||
*/
|
||||
namespace Product {
|
||||
type ProductStatusCode = 'active' | 'paused' | 'archived' | 'abandoned';
|
||||
|
||||
type ProductStatusActionCode = 'pause' | 'resume' | 'archive' | 'abandon';
|
||||
|
||||
type ProductMemberStatus = 0 | 1;
|
||||
|
||||
interface PageParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface PageResult<T = any> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
interface Product {
|
||||
/** 产品 ID */
|
||||
id: string;
|
||||
/** 产品编码 */
|
||||
code: string;
|
||||
/** 产品方向字典值 */
|
||||
directionCode: string;
|
||||
/** 产品名称 */
|
||||
name: string;
|
||||
/** 产品经理用户 ID */
|
||||
managerUserId: string;
|
||||
/** 产品描述 */
|
||||
description?: string | null;
|
||||
/** 产品状态编码 */
|
||||
statusCode: ProductStatusCode;
|
||||
/** 最近一次状态动作原因 */
|
||||
lastStatusReason?: string | null;
|
||||
/** 备注 */
|
||||
remark?: string | null;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
interface ProductSettingBaseInfo {
|
||||
/** 产品 ID */
|
||||
id: string;
|
||||
/** 产品编码 */
|
||||
code: string;
|
||||
/** 产品方向字典值 */
|
||||
directionCode: string;
|
||||
/** 产品名称 */
|
||||
name: string;
|
||||
/** 产品经理用户 ID */
|
||||
managerUserId: string;
|
||||
/** 产品经理昵称 */
|
||||
managerUserNickname: string;
|
||||
/** 产品描述 */
|
||||
description?: string | null;
|
||||
/** 当前产品状态 */
|
||||
statusCode: ProductStatusCode;
|
||||
/** 最近一次状态动作原因 */
|
||||
lastStatusReason?: string | null;
|
||||
}
|
||||
|
||||
interface ProductLifecycleAction {
|
||||
actionCode: ProductStatusActionCode;
|
||||
actionName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface ProductLifecycleInfo {
|
||||
statusCode: ProductStatusCode;
|
||||
lastStatusReason?: string | null;
|
||||
availableActions: ProductLifecycleAction[];
|
||||
}
|
||||
|
||||
interface ProductSettings {
|
||||
baseInfo: ProductSettingBaseInfo;
|
||||
lifecycle: ProductLifecycleInfo;
|
||||
}
|
||||
|
||||
interface ProductMember {
|
||||
/** 团队关系 ID */
|
||||
id: string;
|
||||
/** 用户 ID */
|
||||
userId: string;
|
||||
/** 用户昵称 */
|
||||
userNickname: string;
|
||||
/** 角色 ID */
|
||||
roleId: string;
|
||||
/** 角色名称 */
|
||||
roleName: string;
|
||||
/** 角色编码 */
|
||||
roleCode: string;
|
||||
/** 是否当前产品经理 */
|
||||
managerFlag: boolean;
|
||||
/** 成员状态 */
|
||||
status: ProductMemberStatus;
|
||||
/** 加入时间 */
|
||||
joinedTime: string;
|
||||
/** 退出时间 */
|
||||
leftTime?: string | null;
|
||||
/** 备注 */
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
type ProductSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
|
||||
keyword: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
type SaveProductParams = Pick<Product, 'directionCode' | 'name' | 'managerUserId'> & {
|
||||
code?: string | null;
|
||||
description?: string | null;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
type UpdateProductParams = { id: string } & SaveProductParams;
|
||||
|
||||
interface ChangeProductStatusParams {
|
||||
id: string;
|
||||
actionCode: ProductStatusActionCode;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface DeleteProductParams {
|
||||
id: string;
|
||||
productName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type UpdateProductSettingBaseInfoParams = Pick<ProductSettingBaseInfo, 'directionCode' | 'name'> & {
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
interface CreateProductMemberParams {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
remark?: string | null;
|
||||
previousManagerUserId?: string | null;
|
||||
previousManagerRoleId?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateProductMemberParams {
|
||||
roleId: string;
|
||||
remark?: string | null;
|
||||
reason?: string | null;
|
||||
previousManagerUserId?: string | null;
|
||||
previousManagerRoleId?: string | null;
|
||||
}
|
||||
|
||||
interface InactiveProductMemberParams {
|
||||
reason?: string | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/typings/api/system-manage.d.ts
vendored
71
src/typings/api/system-manage.d.ts
vendored
@@ -19,13 +19,26 @@ declare namespace Api {
|
||||
|
||||
type RoleType = 1 | 2;
|
||||
|
||||
type ScopeType = 'global' | 'object';
|
||||
|
||||
type ObjectType = 'product' | 'project';
|
||||
|
||||
interface ScopeQueryParams {
|
||||
scopeType?: ScopeType;
|
||||
objectType?: ObjectType;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
/** role id */
|
||||
id: number;
|
||||
id: string;
|
||||
/** role name */
|
||||
name: string;
|
||||
/** role code */
|
||||
code: string;
|
||||
/** scope type */
|
||||
scopeType?: ScopeType;
|
||||
/** object type */
|
||||
objectType?: ObjectType | '' | null;
|
||||
/** display sort */
|
||||
sort: number;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
@@ -39,13 +52,12 @@ declare namespace Api {
|
||||
}
|
||||
|
||||
type RoleSearchParams = CommonType.RecordNullable<Pick<Role, 'name' | 'code' | 'status'>> &
|
||||
PageParams & {
|
||||
createTime?: string[];
|
||||
};
|
||||
PageParams & { createTime?: string[] } & ScopeQueryParams;
|
||||
|
||||
type SaveRoleParams = Pick<Role, 'name' | 'code' | 'sort' | 'status'> & {
|
||||
type SaveRoleParams = (Pick<Role, 'name' | 'code' | 'sort' | 'status'> & {
|
||||
remark?: string | null;
|
||||
};
|
||||
}) &
|
||||
ScopeQueryParams;
|
||||
|
||||
type RoleList = PageResult<Role>;
|
||||
|
||||
@@ -149,7 +161,7 @@ declare namespace Api {
|
||||
nickname?: string;
|
||||
mobile?: string;
|
||||
deptId?: number;
|
||||
roleId?: number;
|
||||
roleId?: string;
|
||||
company?: string;
|
||||
}
|
||||
>;
|
||||
@@ -218,7 +230,7 @@ declare namespace Api {
|
||||
|
||||
interface AssignUserRoleParams {
|
||||
userId: number;
|
||||
roleIds: number[];
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,17 +256,21 @@ declare namespace Api {
|
||||
|
||||
interface Menu {
|
||||
/** menu id */
|
||||
id: number;
|
||||
id: string;
|
||||
/** menu name */
|
||||
name: string;
|
||||
/** permission code */
|
||||
permission?: string | null;
|
||||
/** scope type */
|
||||
scopeType?: ScopeType;
|
||||
/** object type */
|
||||
objectType?: ObjectType | '' | null;
|
||||
/** menu type */
|
||||
type: MenuType;
|
||||
/** display sort */
|
||||
sort: number;
|
||||
/** parent menu id */
|
||||
parentId: number;
|
||||
parentId: string;
|
||||
/** route path */
|
||||
path?: string | null;
|
||||
/** menu icon */
|
||||
@@ -281,7 +297,7 @@ declare namespace Api {
|
||||
children?: Menu[] | null;
|
||||
}
|
||||
|
||||
type MenuSearchParams = CommonType.RecordNullable<Pick<Menu, 'name' | 'status'>>;
|
||||
type MenuSearchParams = CommonType.RecordNullable<Pick<Menu, 'name' | 'status'>> & ScopeQueryParams;
|
||||
|
||||
type SaveMenuParams = Pick<
|
||||
Menu,
|
||||
@@ -300,12 +316,15 @@ declare namespace Api {
|
||||
| 'visible'
|
||||
| 'keepAlive'
|
||||
| 'alwaysShow'
|
||||
>;
|
||||
> &
|
||||
ScopeQueryParams;
|
||||
|
||||
interface MenuSimple {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: number;
|
||||
parentId: string;
|
||||
scopeType?: ScopeType;
|
||||
objectType?: ObjectType | '' | null;
|
||||
type: MenuType;
|
||||
children?: MenuSimple[] | null;
|
||||
}
|
||||
@@ -315,8 +334,8 @@ declare namespace Api {
|
||||
type MenuSimpleList = MenuSimple[];
|
||||
|
||||
interface AssignRoleMenuParams {
|
||||
roleId: number;
|
||||
menuIds: number[];
|
||||
roleId: string;
|
||||
menuIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,11 +346,11 @@ declare namespace Api {
|
||||
*/
|
||||
interface UserManagementRelation {
|
||||
/** 主键 ID */
|
||||
id: any;
|
||||
id: string | null;
|
||||
/** 管理者用户 ID */
|
||||
managerUserId: any;
|
||||
managerUserId: string | null;
|
||||
/** 被管理用户 ID */
|
||||
subordinateUserId: any;
|
||||
subordinateUserId: string | null;
|
||||
/** 生效开始时间 */
|
||||
effectiveFrom?: number | null;
|
||||
/** 生效结束时间 */
|
||||
@@ -350,13 +369,13 @@ declare namespace Api {
|
||||
*/
|
||||
interface UserManagementRelationTreeRespVO {
|
||||
/** 关系记录主键 ID(最高领导为 null) */
|
||||
id: number | null;
|
||||
id: string | null;
|
||||
/** 用户 ID */
|
||||
userId: number;
|
||||
userId: string;
|
||||
/** 用户昵称 */
|
||||
userNickname: string;
|
||||
/** 上级用户 ID(最高领导为 null) */
|
||||
managerUserId: number | null;
|
||||
managerUserId: string | null;
|
||||
/** 上级用户昵称(最高领导为 null) */
|
||||
managerNickname: string | null;
|
||||
/** 下级用户列表(基层员工为空列表) */
|
||||
@@ -371,11 +390,11 @@ declare namespace Api {
|
||||
*/
|
||||
interface UserManagementRelationSaveReqVO {
|
||||
/** 主键 ID(更新时需要) */
|
||||
id?: number;
|
||||
id?: string;
|
||||
/** 管理者用户 ID */
|
||||
managerUserId: any;
|
||||
managerUserId: string | null;
|
||||
/** 被管理用户 ID */
|
||||
subordinateUserId: any;
|
||||
subordinateUserId: string | null;
|
||||
/** 生效开始时间 */
|
||||
effectiveFrom?: number | null;
|
||||
/** 生效结束时间 */
|
||||
@@ -406,7 +425,7 @@ declare namespace Api {
|
||||
*/
|
||||
interface UserSimple {
|
||||
/** 用户 ID */
|
||||
id: number;
|
||||
id: string;
|
||||
/** 用户昵称 */
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
47
src/typings/app.d.ts
vendored
47
src/typings/app.d.ts
vendored
@@ -549,6 +549,14 @@ declare namespace App {
|
||||
enable: string;
|
||||
disable: string;
|
||||
};
|
||||
scopeType: {
|
||||
global: string;
|
||||
object: string;
|
||||
};
|
||||
objectType: {
|
||||
product: string;
|
||||
project: string;
|
||||
};
|
||||
};
|
||||
role: {
|
||||
title: string;
|
||||
@@ -569,6 +577,13 @@ declare namespace App {
|
||||
selectedCount: string;
|
||||
disabledTip: string;
|
||||
emptyRole: string;
|
||||
currentRoleCount: string;
|
||||
globalRoleTitle: string;
|
||||
objectRoleTitle: string;
|
||||
globalRoleSummary: string;
|
||||
objectRoleSummary: string;
|
||||
objectRoleSummaryProduct: string;
|
||||
objectRoleSummaryProject: string;
|
||||
lastAuthSave: string;
|
||||
unsavedTip: string;
|
||||
form: {
|
||||
@@ -691,11 +706,15 @@ declare namespace App {
|
||||
menuType: string;
|
||||
menuName: string;
|
||||
permission: string;
|
||||
scopeType: string;
|
||||
objectType: string;
|
||||
resourceCode: string;
|
||||
routeName: string;
|
||||
routePath: string;
|
||||
routeKind: string;
|
||||
routePropsJson: string;
|
||||
pageResource: string;
|
||||
boundRoute: string;
|
||||
component: string;
|
||||
componentName: string;
|
||||
iframeUrl: string;
|
||||
@@ -725,6 +744,29 @@ declare namespace App {
|
||||
alwaysShow: string;
|
||||
createTime: string;
|
||||
topLevel: string;
|
||||
contextEyebrow: string;
|
||||
contextTitle: string;
|
||||
contextDescription: string;
|
||||
currentContext: string;
|
||||
currentResourceCount: string;
|
||||
editorMode: string;
|
||||
editorModeGlobal: string;
|
||||
editorModeObject: string;
|
||||
globalResourceTitle: string;
|
||||
objectResourceTitle: string;
|
||||
globalResourceSummary: string;
|
||||
objectResourceSummary: string;
|
||||
objectResourceSummaryProduct: string;
|
||||
objectResourceSummaryProject: string;
|
||||
scopeHintGlobal: string;
|
||||
scopeHintObject: string;
|
||||
objectTypePlaceholder: string;
|
||||
contextReady: string;
|
||||
contextPending: string;
|
||||
objectTypeRequiredTitle: string;
|
||||
objectTypeRequiredDescription: string;
|
||||
objectModeTipTitle: string;
|
||||
objectModeTipDescription: string;
|
||||
sections: {
|
||||
basic: string;
|
||||
route: string;
|
||||
@@ -736,6 +778,7 @@ declare namespace App {
|
||||
parentId: string;
|
||||
menuName: string;
|
||||
permission: string;
|
||||
resourceCode: string;
|
||||
routeName: string;
|
||||
routePath: string;
|
||||
path: string;
|
||||
@@ -743,6 +786,7 @@ declare namespace App {
|
||||
componentName: string;
|
||||
routeKind: string;
|
||||
pageResource: string;
|
||||
boundRoute: string;
|
||||
pageResourceParentMismatch: string;
|
||||
routePropsJson: string;
|
||||
routePropsJsonHint: string;
|
||||
@@ -784,6 +828,7 @@ declare namespace App {
|
||||
};
|
||||
routePath: string;
|
||||
pageResource: string;
|
||||
boundRoute: string;
|
||||
component: string;
|
||||
};
|
||||
addMenu: string;
|
||||
@@ -793,6 +838,8 @@ declare namespace App {
|
||||
directory: string;
|
||||
menu: string;
|
||||
button: string;
|
||||
navigation: string;
|
||||
actionButton: string;
|
||||
};
|
||||
iconType: {
|
||||
iconify: string;
|
||||
|
||||
5
src/typings/components.d.ts
vendored
5
src/typings/components.d.ts
vendored
@@ -17,6 +17,10 @@ declare module 'vue' {
|
||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
|
||||
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
|
||||
DictSelect: typeof import('./../components/custom/dict-select.vue')['default']
|
||||
DictTag: typeof import('./../components/custom/dict-tag.vue')['default']
|
||||
DictText: typeof import('./../components/custom/dict-text.vue')['default']
|
||||
ElAffix: typeof import('element-plus/es')['ElAffix']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
@@ -105,6 +109,7 @@ declare module 'vue' {
|
||||
IconLocalCast: typeof import('~icons/local/cast')['default']
|
||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
|
||||
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
|
||||
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
|
||||
10
src/typings/elegant-router.d.ts
vendored
10
src/typings/elegant-router.d.ts
vendored
@@ -60,6 +60,11 @@ declare module "@elegant-router/types" {
|
||||
"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";
|
||||
"system": "/system";
|
||||
"system_dict": "/system/dict";
|
||||
"system_menu": "/system/menu";
|
||||
@@ -111,6 +116,7 @@ declare module "@elegant-router/types" {
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "plugin"
|
||||
| "product"
|
||||
| "system"
|
||||
| "user-center"
|
||||
>;
|
||||
@@ -162,6 +168,10 @@ declare module "@elegant-router/types" {
|
||||
| "plugin_tables_vtable"
|
||||
| "plugin_typeit"
|
||||
| "plugin_video"
|
||||
| "product_dashboard"
|
||||
| "product_list"
|
||||
| "product_requirement"
|
||||
| "product_setting"
|
||||
| "system_dict"
|
||||
| "system_menu"
|
||||
| "system_post"
|
||||
|
||||
57
src/typings/object-context.d.ts
vendored
Normal file
57
src/typings/object-context.d.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
declare namespace App {
|
||||
namespace ObjectContext {
|
||||
type DomainKey = 'project' | 'product' | string;
|
||||
|
||||
type ObjectType = 'project' | 'product' | string;
|
||||
|
||||
type Menu = {
|
||||
/** 对象上下文菜单 key,优先约定为目标路由 name */
|
||||
key: string;
|
||||
/** 菜单文案 */
|
||||
label: string;
|
||||
/** 路由 name,可为空 */
|
||||
routeKey?: string | null;
|
||||
/** 路由 path,可为空 */
|
||||
routePath?: string | null;
|
||||
/** 子菜单 */
|
||||
children?: Menu[];
|
||||
};
|
||||
|
||||
interface DomainConfig {
|
||||
domainKey: DomainKey;
|
||||
mode: 'object-context';
|
||||
objectType: ObjectType;
|
||||
/** 用于识别当前路由是否属于该业务域 */
|
||||
routePathPrefixes: string[];
|
||||
/** 业务域入口页 */
|
||||
entryRouteKey: string;
|
||||
entryRoutePath: string;
|
||||
/** 对象默认首页兜底值 */
|
||||
fallbackDefaultRouteKey: string;
|
||||
fallbackDefaultRoutePath: string;
|
||||
/** 上下文接口 */
|
||||
contextApiPath: string;
|
||||
contextApiObjectIdParamKey: string;
|
||||
contextApiObjectIdPlacement?: 'query' | 'path';
|
||||
/** 第一版固定为 objectId */
|
||||
objectIdQueryKey: 'objectId';
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface State {
|
||||
domainKey: DomainKey;
|
||||
objectType: ObjectType;
|
||||
objectId: string;
|
||||
objectName: string;
|
||||
objectSummary: Summary | null;
|
||||
contextScopedMenus: Menu[];
|
||||
buttonCodes: string[];
|
||||
defaultRouteKey: string;
|
||||
defaultRoutePath: string;
|
||||
isReady: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
867
src/views/product/dashboard/index.vue
Normal file
867
src/views/product/dashboard/index.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import { getProductLifecycleStatusSummary } from '../setting/shared';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
|
||||
import {
|
||||
getProductDashboardActivityItems,
|
||||
getProductDashboardGrowthModules,
|
||||
getProductDashboardMetricCards,
|
||||
getProductDashboardQuickLinks,
|
||||
getProductDashboardRdMilestonePlaceholder,
|
||||
getProductDashboardRecentActivityPlaceholder,
|
||||
getProductDashboardTeamSummary
|
||||
} from './shared';
|
||||
|
||||
defineOptions({ name: 'ProductDashboard' });
|
||||
|
||||
const { currentObjectId } = useCurrentProduct();
|
||||
const { routerPush, routerPushByKey } = useRouterPush();
|
||||
|
||||
const pageLoading = ref(false);
|
||||
const productDetail = ref<Api.Product.Product | null>(null);
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
|
||||
const recentActivityPlaceholder = getProductDashboardRecentActivityPlaceholder();
|
||||
const rdMilestonePlaceholder = getProductDashboardRdMilestonePlaceholder();
|
||||
const growthModules = getProductDashboardGrowthModules();
|
||||
const quickLinks = getProductDashboardQuickLinks();
|
||||
const lifecycleTrackItems: Api.Product.ProductStatusCode[] = ['active', 'paused', 'archived', 'abandoned'];
|
||||
|
||||
const metricCards = computed(() => getProductDashboardMetricCards(settings.value, members.value));
|
||||
const statusMetricCard = computed(() => metricCards.value.find(item => item.key === 'status') || null);
|
||||
const secondaryMetricCards = computed(() => metricCards.value.filter(item => item.key !== 'status'));
|
||||
const teamSummary = computed(() => getProductDashboardTeamSummary(settings.value, members.value));
|
||||
const activityItems = computed(() =>
|
||||
getProductDashboardActivityItems(productDetail.value, settings.value, members.value)
|
||||
);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const lifecycleSummary = computed(() =>
|
||||
lifecycle.value ? getProductLifecycleStatusSummary(lifecycle.value.statusCode) : null
|
||||
);
|
||||
const lifecycleReason = computed(
|
||||
() => lifecycle.value?.lastStatusReason || settings.value?.baseInfo.lastStatusReason || ''
|
||||
);
|
||||
|
||||
async function loadDashboardData(objectId: string) {
|
||||
pageLoading.value = true;
|
||||
|
||||
try {
|
||||
const [productResult, settingsResult, membersResult] = await Promise.all([
|
||||
fetchGetProduct(objectId),
|
||||
fetchGetProductSettings(objectId),
|
||||
fetchGetProductMembers(objectId)
|
||||
]);
|
||||
|
||||
productDetail.value = productResult.error ? null : productResult.data || null;
|
||||
settings.value = settingsResult.error ? null : settingsResult.data || null;
|
||||
members.value = membersResult.error ? [] : membersResult.data || [];
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goToQuickLink(target: 'requirement' | 'setting' | 'list') {
|
||||
if (target === 'list') {
|
||||
await routerPush({
|
||||
path: '/product/list'
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === 'requirement') {
|
||||
await routerPushByKey('product_requirement', {
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await routerPushByKey('product_setting', {
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async objectId => {
|
||||
if (!objectId) {
|
||||
productDetail.value = null;
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await loadDashboardData(objectId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="product-dashboard-page">
|
||||
<section class="product-dashboard-page__metrics">
|
||||
<article
|
||||
v-if="statusMetricCard"
|
||||
class="dashboard-status-hero"
|
||||
:class="[lifecycle ? `dashboard-status-hero--${lifecycle.statusCode}` : 'dashboard-status-hero--default']"
|
||||
>
|
||||
<div class="dashboard-status-hero__head">
|
||||
<span class="dashboard-status-hero__label">{{ statusMetricCard.label }}</span>
|
||||
<span class="dashboard-status-hero__meta-chip">{{ lifecycle?.availableActions.length || 0 }} 项动作</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-status-hero__body">
|
||||
<strong class="dashboard-status-hero__value">{{ statusMetricCard.value }}</strong>
|
||||
<p class="dashboard-status-hero__reason">
|
||||
{{ lifecycleReason || '当前没有补充状态原因' }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="dashboard-metric-stack">
|
||||
<article v-for="card in secondaryMetricCards" :key="card.key" class="dashboard-metric-card">
|
||||
<span class="dashboard-metric-card__label">{{ card.label }}</span>
|
||||
<strong class="dashboard-metric-card__value">{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-dashboard-page__main">
|
||||
<div class="product-dashboard-page__primary">
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ recentActivityPlaceholder.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前先展示产品主数据、状态与团队关系可确认的已知动态。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="activityItems.length" class="dashboard-activity-list">
|
||||
<div
|
||||
v-for="item in activityItems"
|
||||
:key="item.key"
|
||||
class="dashboard-activity-item"
|
||||
:class="[`dashboard-activity-item--${item.tone}`]"
|
||||
>
|
||||
<div class="dashboard-activity-item__meta">
|
||||
<span class="dashboard-activity-item__tag">{{ item.tag }}</span>
|
||||
<span class="dashboard-activity-item__time">{{ item.time }}</span>
|
||||
</div>
|
||||
<strong class="dashboard-activity-item__title">{{ item.title }}</strong>
|
||||
<p class="dashboard-activity-item__content">{{ item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-placeholder-panel">
|
||||
<p class="dashboard-placeholder-panel__description">{{ recentActivityPlaceholder.description }}</p>
|
||||
<div class="dashboard-placeholder-panel__items">
|
||||
<div
|
||||
v-for="item in recentActivityPlaceholder.items"
|
||||
:key="item"
|
||||
class="dashboard-placeholder-panel__item"
|
||||
>
|
||||
<span class="dashboard-placeholder-panel__dot" />
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">生命周期概览</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">
|
||||
{{ lifecycleSummary?.description || '当前未获取到生命周期信息。' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="lifecycle" class="dashboard-lifecycle">
|
||||
<div class="dashboard-lifecycle__summary">
|
||||
<div class="dashboard-lifecycle__summary-main">
|
||||
<ElTag :type="getProductStatusTagType(lifecycle.statusCode)" round effect="light">
|
||||
{{ getProductStatusLabel(lifecycle.statusCode) }}
|
||||
</ElTag>
|
||||
<strong class="dashboard-lifecycle__summary-title">
|
||||
{{ lifecycleSummary?.caption || '当前状态待确认' }}
|
||||
</strong>
|
||||
</div>
|
||||
<p class="dashboard-lifecycle__reason">最近状态原因:{{ lifecycleReason || '暂无记录' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-lifecycle__actions">
|
||||
<div
|
||||
v-for="action in lifecycle.availableActions"
|
||||
:key="action.actionCode"
|
||||
class="dashboard-lifecycle__action-card"
|
||||
>
|
||||
<strong class="dashboard-lifecycle__action-name">{{ action.actionName }}</strong>
|
||||
<span class="dashboard-lifecycle__action-hint">
|
||||
{{ action.needReason ? '提交时需填写原因' : '提交时原因可选' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ElEmpty
|
||||
v-if="!lifecycle.availableActions.length"
|
||||
description="当前状态下没有可执行动作"
|
||||
:image-size="68"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-lifecycle__track">
|
||||
<div
|
||||
v-for="status in lifecycleTrackItems"
|
||||
:key="status"
|
||||
class="dashboard-lifecycle__track-item"
|
||||
:class="[{ 'dashboard-lifecycle__track-item--active': lifecycle.statusCode === status }]"
|
||||
>
|
||||
{{ getProductStatusLabel(status) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="未获取到产品生命周期信息" :image-size="76" />
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="product-dashboard-page__secondary">
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">团队摘要</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前先展示有效成员、负责人和角色分布摘要。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-team-card">
|
||||
<div class="dashboard-team-card__stat-grid">
|
||||
<div class="dashboard-team-card__stat">
|
||||
<span class="dashboard-team-card__stat-label">当前经理</span>
|
||||
<strong class="dashboard-team-card__stat-value">{{ teamSummary.managerDisplayName }}</strong>
|
||||
</div>
|
||||
<div class="dashboard-team-card__stat">
|
||||
<span class="dashboard-team-card__stat-label">有效成员</span>
|
||||
<strong class="dashboard-team-card__stat-value">{{ teamSummary.activeMemberCount }} 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-team-card__detail">
|
||||
<span class="dashboard-team-card__detail-label">最近加入</span>
|
||||
<strong class="dashboard-team-card__detail-value">{{ teamSummary.latestJoinedMemberLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-team-card__roles">
|
||||
<span class="dashboard-team-card__detail-label">角色分布</span>
|
||||
<div v-if="teamSummary.roleSummaries.length" class="dashboard-team-card__role-list">
|
||||
<span v-for="item in teamSummary.roleSummaries" :key="item" class="dashboard-team-card__role-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="64" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">快捷入口</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">首页只做导流,不在这里承接重表单和重列表。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-link-list">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.key"
|
||||
type="button"
|
||||
class="dashboard-link-list__item"
|
||||
@click="goToQuickLink(link.key)"
|
||||
>
|
||||
<strong class="dashboard-link-list__title">{{ link.label }}</strong>
|
||||
<span class="dashboard-link-list__desc">{{ link.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ rdMilestonePlaceholder.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">对象档案补充位,后续接真实聚合数据后直接替换。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-placeholder-panel dashboard-placeholder-panel--compact">
|
||||
<p class="dashboard-placeholder-panel__description">
|
||||
{{ rdMilestonePlaceholder.description }}
|
||||
</p>
|
||||
<div class="dashboard-placeholder-panel__items">
|
||||
<div v-for="item in rdMilestonePlaceholder.items" :key="item" class="dashboard-placeholder-panel__item">
|
||||
<span class="dashboard-placeholder-panel__dot" />
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="product-dashboard-page__growth">
|
||||
<ElCard v-for="module in growthModules" :key="module.key" class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">{{ module.title }}</h3>
|
||||
<p class="mt-4px text-13px text-[#64748b]">当前保留正式布局位,后续可直接接入真实统计。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-growth-card">
|
||||
<p class="dashboard-growth-card__description">{{ module.description }}</p>
|
||||
<div class="dashboard-growth-card__indicators">
|
||||
<div v-for="item in module.indicators" :key="item" class="dashboard-growth-card__indicator">
|
||||
<span class="dashboard-growth-card__indicator-label">{{ item }}</span>
|
||||
<strong class="dashboard-growth-card__indicator-value">--</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 152px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero--default {
|
||||
background: linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--active {
|
||||
border-color: rgb(167 243 208 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(16 185 129 / 16%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(236 253 245 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--paused {
|
||||
border-color: rgb(253 230 138 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(245 158 11 / 16%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(255 251 235 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--archived {
|
||||
border-color: rgb(203 213 225 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(100 116 139 / 14%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero--abandoned {
|
||||
border-color: rgb(254 205 211 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(244 63 94 / 14%), transparent 38%),
|
||||
linear-gradient(135deg, rgb(255 241 242 / 98%), rgb(255 255 255 / 98%));
|
||||
}
|
||||
|
||||
.dashboard-status-hero__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(255 255 255 / 78%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__value {
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 38px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dashboard-status-hero__reason {
|
||||
color: rgb(51 65 85 / 92%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-metric-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-metric-card {
|
||||
display: flex;
|
||||
min-height: 152px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.dashboard-metric-card__label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-metric-card__value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dashboard-metric-card--manager .dashboard-metric-card__value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-dashboard-page__primary,
|
||||
.product-dashboard-page__secondary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel--compact {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__description {
|
||||
color: rgb(71 85 105 / 95%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 94%);
|
||||
color: rgb(15 23 42 / 92%);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-placeholder-panel__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(14 165 233 / 86%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-left-width: 4px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--sky {
|
||||
border-left-color: rgb(14 165 233 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--emerald {
|
||||
border-left-color: rgb(5 150 105 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--amber {
|
||||
border-left-color: rgb(217 119 6 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--rose {
|
||||
border-left-color: rgb(225 29 72 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item--slate {
|
||||
border-left-color: rgb(71 85 105 / 88%);
|
||||
}
|
||||
|
||||
.dashboard-activity-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 94%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__time {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dashboard-activity-item__content {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__reason {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-card {
|
||||
display: flex;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 94%);
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-name {
|
||||
color: rgb(15 23 42 / 95%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__action-hint {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(241 245 249 / 98%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__track-item--active {
|
||||
background-color: rgb(15 23 42 / 92%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-team-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-label,
|
||||
.dashboard-team-card__detail-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__stat-value,
|
||||
.dashboard-team-card__detail-value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-team-card__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__roles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__role-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-team-card__role-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(239 246 255 / 96%);
|
||||
color: rgb(30 64 175 / 92%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-link-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-link-list__item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 98%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-link-list__item:hover {
|
||||
border-color: rgb(125 211 252 / 92%);
|
||||
box-shadow: 0 12px 24px rgb(148 163 184 / 12%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dashboard-link-list__title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboard-link-list__desc {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.product-dashboard-page__growth {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__description {
|
||||
color: rgb(71 85 105 / 95%);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicators {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator-label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-growth-card__indicator-value {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-dashboard-page__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-dashboard-page__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-metric-stack,
|
||||
.product-dashboard-page__growth {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.dashboard-metric-stack,
|
||||
.product-dashboard-page__growth,
|
||||
.dashboard-lifecycle__actions,
|
||||
.dashboard-growth-card__indicators,
|
||||
.dashboard-team-card__stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-lifecycle__summary-main {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
267
src/views/product/dashboard/shared.ts
Normal file
267
src/views/product/dashboard/shared.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
696
src/views/product/list/index.vue
Normal file
696
src/views/product/list/index.vue
Normal file
@@ -0,0 +1,696 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
|
||||
import ProductOperateDialog from './modules/product-operate-dialog.vue';
|
||||
import ProductSearch from './modules/product-search.vue';
|
||||
|
||||
defineOptions({ name: 'ProductList' });
|
||||
|
||||
interface StatusNavMeta {
|
||||
key: Api.Product.ProductStatusCode;
|
||||
label: string;
|
||||
description: string;
|
||||
tone: 'teal' | 'slate' | 'amber' | 'rose';
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
||||
|
||||
const PRODUCT_OPTION_PAGE_SIZE = 200;
|
||||
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||
|
||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
managerUserId: undefined,
|
||||
statusCode: undefined,
|
||||
updateTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformProductPage(response: ProductPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
|
||||
const { error, data } = await fetchGetProductPage({
|
||||
...params,
|
||||
pageNo: 1,
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data.total;
|
||||
}
|
||||
|
||||
async function fetchAllProducts() {
|
||||
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
|
||||
const { error, data } = await fetchGetProductPage({
|
||||
pageNo,
|
||||
pageSize: PRODUCT_OPTION_PAGE_SIZE
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextList = list.concat(data.list);
|
||||
|
||||
if (nextList.length >= data.total || data.list.length === 0) {
|
||||
return nextList;
|
||||
}
|
||||
|
||||
return collect(pageNo + 1, nextList);
|
||||
}
|
||||
|
||||
return collect(1, []);
|
||||
}
|
||||
|
||||
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
|
||||
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
|
||||
const userMap = new Map(users.map(item => [String(item.id), item]));
|
||||
|
||||
const options = Array.from(managerIdSet).map(managerUserId => {
|
||||
return (
|
||||
userMap.get(managerUserId) || {
|
||||
id: managerUserId,
|
||||
nickname: String(managerUserId)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return sortManagerOptions(options);
|
||||
}
|
||||
|
||||
const statusNavMetas: StatusNavMeta[] = [
|
||||
{
|
||||
key: 'active',
|
||||
label: '启用产品',
|
||||
description: '当前正常服务中的产品',
|
||||
tone: 'teal',
|
||||
icon: CircleCheckFilled
|
||||
},
|
||||
{
|
||||
key: 'archived',
|
||||
label: '归档产品',
|
||||
description: '已完成阶段目标的产品',
|
||||
tone: 'slate',
|
||||
icon: FolderOpened
|
||||
},
|
||||
{
|
||||
key: 'paused',
|
||||
label: '暂停产品',
|
||||
description: '阶段性暂停投入的产品',
|
||||
tone: 'amber',
|
||||
icon: VideoPause
|
||||
},
|
||||
{
|
||||
key: 'abandoned',
|
||||
label: '废弃产品',
|
||||
description: '已明确停止建设的产品',
|
||||
tone: 'rose',
|
||||
icon: DeleteFilled
|
||||
}
|
||||
];
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
|
||||
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
const editingRow = ref<Api.Product.Product | null>(null);
|
||||
const { routerPush } = useRouterPush();
|
||||
|
||||
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
|
||||
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
|
||||
active: 0,
|
||||
archived: 0,
|
||||
paused: 0,
|
||||
abandoned: 0
|
||||
});
|
||||
|
||||
const recentUpdatedCount = ref(0);
|
||||
|
||||
const managerLabelMap = computed(() => {
|
||||
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
const statusItems = computed(() =>
|
||||
statusNavMetas.map(item => ({
|
||||
...item,
|
||||
count: statusCounts.value[item.key]
|
||||
}))
|
||||
);
|
||||
|
||||
const overviewMetrics = computed(() => [
|
||||
{
|
||||
label: '可见产品',
|
||||
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
|
||||
hint: '当前接口可查询到的产品总量'
|
||||
},
|
||||
{
|
||||
label: '当前启用',
|
||||
value: statusCounts.value.active,
|
||||
hint: '正在持续服务和维护的产品'
|
||||
},
|
||||
{
|
||||
label: '产品方向',
|
||||
value: directionOptions.value.length,
|
||||
hint: '已加载的方向字典项数量'
|
||||
},
|
||||
{
|
||||
label: '30天内更新',
|
||||
value: recentUpdatedCount.value,
|
||||
hint: '最近 30 天内发生过更新的产品'
|
||||
}
|
||||
]);
|
||||
|
||||
function getDirectionLabel(directionCode?: string | null) {
|
||||
return getDirectionDictLabel(directionCode, '--');
|
||||
}
|
||||
|
||||
function getManagerLabel(managerUserId?: string | null) {
|
||||
if (!managerUserId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
}
|
||||
|
||||
function createRequestParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
...searchParams,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: selectedStatus.value
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
ProductPageResponse,
|
||||
Api.Product.Product
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetProductPage(createRequestParams()),
|
||||
transform: response => transformProductPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{
|
||||
prop: 'name',
|
||||
label: '产品名称',
|
||||
minWidth: 220,
|
||||
formatter: row => (
|
||||
<ElButton link type="primary" class="product-name-link" onClick={() => enterProductContext(row)}>
|
||||
{row.name}
|
||||
</ElButton>
|
||||
)
|
||||
},
|
||||
{ prop: 'code', label: '产品编码', minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'managerUserId',
|
||||
label: '产品经理',
|
||||
minWidth: 120,
|
||||
formatter: row => getManagerLabel(row.managerUserId)
|
||||
},
|
||||
{
|
||||
prop: 'directionCode',
|
||||
label: '产品方向',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getDirectionLabel(row.directionCode)
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '管理状态',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={getProductStatusTagType(row.statusCode)}>{getProductStatusLabel(row.statusCode)}</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'lastStatusReason',
|
||||
label: '状态原因',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.lastStatusReason?.trim() || '--'
|
||||
},
|
||||
{
|
||||
prop: 'updateTime',
|
||||
label: '最近更新',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
formatter: row => formatDateTime(row.updateTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 108,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
buttonType: 'primary',
|
||||
disabled: !isProductEditable(row.statusCode),
|
||||
onClick: () => openEdit(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
async function loadManagerOptions() {
|
||||
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
|
||||
|
||||
const userSimpleList =
|
||||
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
|
||||
|
||||
managerUserOptions.value = userSimpleList;
|
||||
|
||||
if (!allProducts) {
|
||||
managerFilterOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
|
||||
}
|
||||
|
||||
async function loadOverviewData() {
|
||||
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
|
||||
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
|
||||
]);
|
||||
|
||||
statusCounts.value = {
|
||||
active: activeTotal,
|
||||
archived: archivedTotal,
|
||||
paused: pausedTotal,
|
||||
abandoned: abandonedTotal
|
||||
};
|
||||
recentUpdatedCount.value = recentTotal;
|
||||
}
|
||||
|
||||
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
async function refreshPageData(page = searchParams.pageNo ?? 1) {
|
||||
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize
|
||||
});
|
||||
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
|
||||
selectedStatus.value = status;
|
||||
await reloadProductTable(1);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingRow.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.Product.Product) {
|
||||
editingRow.value = row;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
async function enterProductContext(row: Api.Product.Product) {
|
||||
await routerPush({
|
||||
path: PRODUCT_ENTRY_ROUTE_PATH,
|
||||
query: {
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: row.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleProductSubmitted(productId?: string) {
|
||||
const isEditing = Boolean(productId && editingRow.value?.id === productId);
|
||||
|
||||
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
||||
|
||||
if (isEditing) {
|
||||
editingRow.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshPageData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ElCard class="product-overview-card card-wrapper">
|
||||
<div class="product-overview-card__stats">
|
||||
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
|
||||
<span class="product-overview-card__stat-label">{{ item.label }}</span>
|
||||
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
|
||||
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-status-panel__list">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="product-status-item"
|
||||
:class="[`product-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
|
||||
:aria-pressed="selectedStatus === item.key"
|
||||
@click="handleStatusChange(item.key)"
|
||||
>
|
||||
<div class="product-status-item__icon">
|
||||
<ElIcon>
|
||||
<component :is="item.icon" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="product-status-item__main">
|
||||
<div class="product-status-item__top">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<em>{{ item.count }}</em>
|
||||
</div>
|
||||
<p class="product-status-item__desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<ProductSearch
|
||||
v-model:model="searchParams"
|
||||
:manager-options="managerFilterOptions"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
|
||||
<template #header>
|
||||
<div class="product-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">产品列表</p>
|
||||
<ElTag effect="plain" :type="getProductStatusTagType(selectedStatus)">
|
||||
{{
|
||||
statusItems.find(item => item.key === selectedStatus)?.label ||
|
||||
getProductStatusLabel(selectedStatus)
|
||||
}}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="refreshPageData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无产品" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<ProductOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:manager-user-options="managerUserOptions"
|
||||
:row-data="editingRow"
|
||||
@submitted="handleProductSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-overview-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 8%), transparent 36%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.product-overview-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-overview-card__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 84%);
|
||||
}
|
||||
|
||||
.product-overview-card__stat-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.product-overview-card__stat-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.product-overview-card__stat-hint {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.product-status-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.product-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 86%);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.product-status-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 60%);
|
||||
}
|
||||
|
||||
.product-status-item.is-active {
|
||||
border-color: rgb(15 118 110 / 40%);
|
||||
box-shadow: 0 10px 24px rgb(15 118 110 / 8%);
|
||||
}
|
||||
|
||||
.product-status-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.product-status-item__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-status-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status-item__top strong {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-status-item__top em {
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-status-item__desc {
|
||||
color: rgb(100 116 139 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-status-item--teal .product-status-item__icon {
|
||||
background-color: rgb(240 253 250 / 96%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
}
|
||||
|
||||
.product-status-item--slate .product-status-item__icon {
|
||||
background-color: rgb(241 245 249 / 96%);
|
||||
color: rgb(51 65 85 / 92%);
|
||||
}
|
||||
|
||||
.product-status-item--amber .product-status-item__icon {
|
||||
background-color: rgb(255 251 235 / 96%);
|
||||
color: rgb(217 119 6 / 92%);
|
||||
}
|
||||
|
||||
.product-status-item--rose .product-status-item__icon {
|
||||
background-color: rgb(255 241 242 / 96%);
|
||||
color: rgb(225 29 72 / 92%);
|
||||
}
|
||||
|
||||
:deep(.product-table-card-body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-name-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-card-header,
|
||||
.product-status-item__top {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.product-overview-card__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
111
src/views/product/list/modules/product-detail-dialog.vue
Normal file
111
src/views/product/list/modules/product-detail-dialog.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetProduct } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
|
||||
defineOptions({ name: 'ProductDetailDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Product.Product | null;
|
||||
managerOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const detailLoading = ref(false);
|
||||
const detailData = ref<Api.Product.Product | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
return detailData.value?.name ? `产品详情 - ${detailData.value.name}` : '产品详情';
|
||||
});
|
||||
|
||||
const managerLabelMap = computed(() => {
|
||||
return new Map(props.managerOptions.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
function getManagerLabel(managerUserId?: string | null) {
|
||||
if (!managerUserId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function initDetail() {
|
||||
detailData.value = props.rowData ? { ...props.rowData } : null;
|
||||
|
||||
if (!props.rowData) {
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProduct(props.rowData.id);
|
||||
|
||||
detailLoading.value = false;
|
||||
|
||||
if (!error) {
|
||||
detailData.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
initDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:loading="detailLoading"
|
||||
:show-footer="false"
|
||||
:scrollbar="false"
|
||||
>
|
||||
<template v-if="detailData">
|
||||
<div class="mb-16px flex flex-wrap items-center gap-8px">
|
||||
<ElTag>{{ detailData.code }}</ElTag>
|
||||
<ElTag :type="getProductStatusTagType(detailData.statusCode)">
|
||||
{{ getProductStatusLabel(detailData.statusCode) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="产品名称">{{ detailData.name }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品方向">
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="detailData.directionCode" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品经理">{{ getManagerLabel(detailData.managerUserId) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="创建时间">{{ formatTime(detailData.createTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="更新时间">{{ formatTime(detailData.updateTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近状态原因">{{ detailData.lastStatusReason || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品描述" :span="2">
|
||||
<span class="whitespace-pre-wrap">{{ detailData.description || '--' }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到产品详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
140
src/views/product/list/modules/product-entry-card.vue
Normal file
140
src/views/product/list/modules/product-entry-card.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type DemoProduct, getProductHealthType, getProductStatusType } from '@/constants/product-demo';
|
||||
|
||||
defineOptions({ name: 'ProductEntryCard' });
|
||||
|
||||
interface Props {
|
||||
product: DemoProduct;
|
||||
entering?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
entering: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter', product: DemoProduct): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const quickFacts = computed(() => [
|
||||
{ label: '版本', value: props.product.version },
|
||||
{ label: '目标发版', value: props.product.releaseTarget },
|
||||
{ label: '团队规模', value: `${props.product.teamCount} 人` }
|
||||
]);
|
||||
|
||||
function handleEnter() {
|
||||
emit('enter', props.product);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-entry-card h-full">
|
||||
<div class="mb-14px flex items-start justify-between gap-12px">
|
||||
<div class="min-w-0">
|
||||
<div class="mb-8px flex flex-wrap items-center gap-8px">
|
||||
<span class="product-entry-card__code">{{ product.code }}</span>
|
||||
<ElTag :type="getProductStatusType(product.status)" round>{{ product.status }}</ElTag>
|
||||
<ElTag :type="getProductHealthType(product.health)" effect="dark" round>{{ product.health }}</ElTag>
|
||||
</div>
|
||||
<h3 class="mb-6px text-18px text-[#0f172a] font-700">{{ product.name }}</h3>
|
||||
<p class="text-13px text-[#64748b]">负责人:{{ product.owner }} / 阶段:{{ product.stage }}</p>
|
||||
</div>
|
||||
<div class="product-entry-card__pulse"></div>
|
||||
</div>
|
||||
|
||||
<p class="mb-14px min-h-[66px] text-14px text-[#475569] leading-22px">{{ product.summary }}</p>
|
||||
|
||||
<div class="mb-14px flex flex-wrap gap-8px">
|
||||
<ElTag v-for="tag in product.tags" :key="tag" effect="plain" round>{{ tag }}</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="grid mb-14px gap-10px sm:grid-cols-3">
|
||||
<div v-for="item in quickFacts" :key="item.label" class="product-entry-card__fact">
|
||||
<span class="product-entry-card__fact-label">{{ item.label }}</span>
|
||||
<strong class="product-entry-card__fact-value">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-16px rounded-16px bg-[#f8fafc] p-12px">
|
||||
<p class="mb-8px text-12px text-[#94a3b8] tracking-[0.08em] uppercase">当前聚焦</p>
|
||||
<div class="flex flex-wrap gap-8px">
|
||||
<span v-for="item in product.focus" :key="item" class="product-entry-card__focus-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="text-13px text-[#64748b]">
|
||||
<span>需求 {{ product.requirementCount }}</span>
|
||||
<span class="mx-8px text-[#cbd5e1]">|</span>
|
||||
<span>缺陷 {{ product.bugCount }}</span>
|
||||
</div>
|
||||
<ElButton type="primary" :loading="entering" @click="handleEnter">进入产品</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-entry-card {
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(16 185 129 / 7%), transparent 28%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
|
||||
}
|
||||
|
||||
.product-entry-card__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(15 23 42 / 92%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.product-entry-card__pulse {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgb(14 165 233 / 82%), rgb(14 165 233 / 16%));
|
||||
box-shadow: 0 0 0 6px rgb(14 165 233 / 8%);
|
||||
}
|
||||
|
||||
.product-entry-card__fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background-color: rgb(241 245 249 / 88%);
|
||||
}
|
||||
|
||||
.product-entry-card__fact-label {
|
||||
color: rgb(100 116 139 / 88%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-entry-card__fact-value {
|
||||
color: rgb(15 23 42 / 90%);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.product-entry-card__focus-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px dashed rgb(125 211 252 / 80%);
|
||||
border-radius: 999px;
|
||||
color: rgb(14 116 144 / 92%);
|
||||
font-size: 12px;
|
||||
background-color: rgb(236 254 255 / 88%);
|
||||
}
|
||||
</style>
|
||||
267
src/views/product/list/modules/product-operate-dialog.vue
Normal file
267
src/views/product/list/modules/product-operate-dialog.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'ProductOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
managerUserOptions: Api.SystemManage.UserSimple[];
|
||||
rowData?: Api.Product.Product | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', productId?: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
interface Model {
|
||||
code: string;
|
||||
directionCode: string;
|
||||
name: string;
|
||||
managerUserId: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const isEditMode = computed(() => Boolean(props.rowData?.id));
|
||||
const dialogTitle = computed(() => (isEditMode.value ? '编辑产品' : '新增产品'));
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
|
||||
const managerDisplayName = computed(() => {
|
||||
const managerUserId = model.value.managerUserId;
|
||||
|
||||
if (!managerUserId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
directionCode: [createRequiredRule('请选择产品方向')],
|
||||
name: [createRequiredRule('请输入产品名称')],
|
||||
managerUserId: [createRequiredRule('请选择产品经理')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
code: '',
|
||||
directionCode: '',
|
||||
name: '',
|
||||
managerUserId: null,
|
||||
description: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
const managerUserId = model.value.managerUserId;
|
||||
|
||||
if (!managerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.Product.SaveProductParams = {
|
||||
code: getNullableText(model.value.code),
|
||||
directionCode: model.value.directionCode,
|
||||
name: model.value.name.trim(),
|
||||
// Long ID 必须以 string 提交,禁止再转成 number。
|
||||
managerUserId,
|
||||
description: getNullableText(model.value.description)
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
if (isEditMode.value && props.rowData?.id) {
|
||||
const result = await fetchUpdateProduct({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品编辑成功');
|
||||
closeDialog();
|
||||
emit('submitted', props.rowData.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchCreateProduct(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品新增成功');
|
||||
closeDialog();
|
||||
emit('submitted', result.data);
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditMode.value || !props.rowData?.id) {
|
||||
model.value = createDefaultModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProduct(props.rowData.id);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = {
|
||||
code: data.code || '',
|
||||
directionCode: data.directionCode || '',
|
||||
name: data.name || '',
|
||||
managerUserId: data.managerUserId || null,
|
||||
description: data.description || ''
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
|
||||
<ElInput
|
||||
:model-value="model.code"
|
||||
readonly
|
||||
class="product-operate-dialog__readonly-input"
|
||||
placeholder="未获取到产品编码"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="产品编码" prop="code">
|
||||
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品名称" prop="name">
|
||||
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品方向" prop="directionCode">
|
||||
<DictSelect
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="请选择产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="isEditMode">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<ElTooltip
|
||||
content="如需调整产品经理,请到产品内的团队管理处处理。"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
<span class="business-form-label-tip">
|
||||
<icon-fe:question />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<span>产品经理</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElInput
|
||||
:model-value="managerDisplayName"
|
||||
readonly
|
||||
class="product-operate-dialog__readonly-input"
|
||||
placeholder="未配置产品经理"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="产品经理" prop="managerUserId">
|
||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
|
||||
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="产品描述" prop="description">
|
||||
<ElInput
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__wrapper) {
|
||||
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;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.product-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.product-operate-dialog__readonly-input .el-input__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
59
src/views/product/list/modules/product-search.vue
Normal file
59
src/views/product/list/modules/product-search.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
|
||||
defineOptions({ name: 'ProductSearch' });
|
||||
|
||||
interface Props {
|
||||
managerOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="关键词">
|
||||
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="产品经理">
|
||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
|
||||
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="产品方向">
|
||||
<DictSelect
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="筛选产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/product/requirement/index.vue
Normal file
7
src/views/product/requirement/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ProductRequirement' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>待开发</h1>
|
||||
</template>
|
||||
505
src/views/product/setting/index.vue
Normal file
505
src/views/product/setting/index.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import {
|
||||
fetchChangeProductStatus,
|
||||
fetchCreateProductMember,
|
||||
fetchDeleteProduct,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductSettings,
|
||||
fetchGetRoleSimpleList,
|
||||
fetchGetUserSimpleList,
|
||||
fetchInactiveProductMember,
|
||||
fetchUpdateProductMember,
|
||||
fetchUpdateProductSettingBaseInfo
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
||||
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
||||
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
||||
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
|
||||
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
|
||||
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
|
||||
import SettingDangerZone from './modules/setting-danger-zone.vue';
|
||||
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
|
||||
import SettingTeamPanel from './modules/setting-team-panel.vue';
|
||||
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||
import {
|
||||
type ProductSettingSectionKey,
|
||||
canManageProductTeam,
|
||||
getProductSettingSectionKeys,
|
||||
resolveVisibleProductSettingSectionKey,
|
||||
resolveVisibleProductSettingSections
|
||||
} from './shared';
|
||||
|
||||
defineOptions({ name: 'ProductSetting' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPush } = useRouterPush();
|
||||
const { currentObjectId, currentProduct } = useCurrentProduct();
|
||||
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
|
||||
|
||||
const productDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'product') || null;
|
||||
|
||||
const allAnchorItems = [
|
||||
{ key: 'base-info', label: '基础信息' },
|
||||
{ key: 'team', label: '团队管理' },
|
||||
{ key: 'lifecycle', label: '生命周期管理' },
|
||||
{ key: 'danger', label: '危险操作' }
|
||||
] as const;
|
||||
|
||||
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
|
||||
|
||||
const sectionIdMap: Record<ProductSettingSectionKey, string> = {
|
||||
'base-info': 'product-setting-base-info',
|
||||
team: 'product-setting-team',
|
||||
lifecycle: 'product-setting-lifecycle',
|
||||
danger: 'product-setting-danger'
|
||||
};
|
||||
|
||||
const activeAnchorKey = ref<ProductSettingSectionKey>('base-info');
|
||||
const pageLoading = ref(false);
|
||||
const memberLoading = ref(false);
|
||||
const baseInfoVisible = ref(false);
|
||||
const memberOperateVisible = ref(false);
|
||||
const memberRemoveVisible = ref(false);
|
||||
const statusActionVisible = ref(false);
|
||||
const deleteVisible = ref(false);
|
||||
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||
const selectedMember = ref<Api.Product.ProductMember | null>(null);
|
||||
const selectedAction = ref<Api.Product.ProductLifecycleAction | null>(null);
|
||||
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
|
||||
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
|
||||
const baseInfo = computed(() => settings.value?.baseInfo || null);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const canManageTeam = computed(() =>
|
||||
canManageProductTeam({
|
||||
buttonCodes: objectContextStore.buttonCodes,
|
||||
loginUserId: authStore.userInfo.userId,
|
||||
currentManagerUserId: currentManager.value?.userId
|
||||
})
|
||||
);
|
||||
const visibleSectionKeys = computed(() =>
|
||||
resolveVisibleProductSettingSections(getProductSettingSectionKeys(), objectContextStore.buttonCodes)
|
||||
);
|
||||
const anchorItems = computed(() =>
|
||||
visibleSectionKeys.value.map(key => ({
|
||||
key,
|
||||
label: anchorLabelMap.get(key) || key
|
||||
}))
|
||||
);
|
||||
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
|
||||
const anchorAffixOffset = computed(() => {
|
||||
const fixedTopInset = themeStore.fixedHeaderAndTab
|
||||
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
|
||||
: 0;
|
||||
|
||||
return fixedTopInset + 16;
|
||||
});
|
||||
const anchorShellInlineStyle = computed(() => ({
|
||||
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
|
||||
}));
|
||||
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
|
||||
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
|
||||
|
||||
async function loadSettings() {
|
||||
if (!currentObjectId.value) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProductSettings(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
settings.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
settings.value = data;
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!currentObjectId.value) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
memberLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProductMembers(currentObjectId.value);
|
||||
|
||||
memberLoading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
members.value = data;
|
||||
}
|
||||
|
||||
async function loadRoleOptions() {
|
||||
const { error, data } = await fetchGetRoleSimpleList({
|
||||
scopeType: 'object',
|
||||
objectType: 'product'
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
roleOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
roleOptions.value = data;
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
|
||||
if (error || !data) {
|
||||
userOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
userOptions.value = data;
|
||||
}
|
||||
|
||||
async function refreshContextSummary() {
|
||||
if (!productDomainConfig || !currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await objectContextStore.enterContext(productDomainConfig, currentObjectId.value);
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoading.value = true;
|
||||
|
||||
await Promise.all([loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
|
||||
|
||||
pageLoading.value = false;
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
if (!(key in sectionIdMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedKey = key as ProductSettingSectionKey;
|
||||
|
||||
if (!visibleSectionKeys.value.includes(resolvedKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeAnchorKey.value = resolvedKey;
|
||||
const target = document.getElementById(sectionIdMap[resolvedKey]);
|
||||
|
||||
target?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateMember() {
|
||||
memberOperateMode.value = 'create';
|
||||
selectedMember.value = null;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditMember(member: Api.Product.ProductMember) {
|
||||
memberOperateMode.value = 'edit';
|
||||
selectedMember.value = member;
|
||||
memberOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openRemoveMember(member: Api.Product.ProductMember) {
|
||||
selectedMember.value = member;
|
||||
memberRemoveVisible.value = true;
|
||||
}
|
||||
|
||||
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
|
||||
selectedAction.value = action;
|
||||
statusActionVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmitBaseInfo(payload: Api.Product.UpdateProductSettingBaseInfoParams) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchUpdateProductSettingBaseInfo(currentObjectId.value, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('基础信息更新成功');
|
||||
baseInfoVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitMemberOperate(event: {
|
||||
mode: 'create' | 'edit';
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result =
|
||||
event.mode === 'create'
|
||||
? await fetchCreateProductMember(currentObjectId.value, event.payload as Api.Product.CreateProductMemberParams)
|
||||
: await fetchUpdateProductMember(
|
||||
currentObjectId.value,
|
||||
event.memberId || '',
|
||||
event.payload as Api.Product.UpdateProductMemberParams
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
|
||||
memberOperateVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
|
||||
if (event.managerChanged) {
|
||||
await refreshContextSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
|
||||
if (!currentObjectId.value || !selectedMember.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchInactiveProductMember(currentObjectId.value, selectedMember.value.id, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('成员移出成功');
|
||||
memberRemoveVisible.value = false;
|
||||
|
||||
await Promise.all([loadMembers(), loadSettings()]);
|
||||
}
|
||||
|
||||
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
|
||||
if (!currentObjectId.value || !selectedAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchChangeProductStatus({
|
||||
...payload,
|
||||
id: currentObjectId.value
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(`${selectedAction.value.actionName}成功`);
|
||||
statusActionVisible.value = false;
|
||||
|
||||
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||
}
|
||||
|
||||
async function handleSubmitDelete(payload: Api.Product.DeleteProductParams) {
|
||||
const result = await fetchDeleteProduct(payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('产品删除成功');
|
||||
deleteVisible.value = false;
|
||||
objectContextStore.clearContext();
|
||||
|
||||
await routerPush({
|
||||
path: '/product/list'
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
visibleSectionKeys,
|
||||
sectionKeys => {
|
||||
activeAnchorKey.value = resolveVisibleProductSettingSectionKey(activeAnchorKey.value, sectionKeys);
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async objectId => {
|
||||
if (!objectId) {
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPageData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="product-setting-page">
|
||||
<div class="product-setting-page__body">
|
||||
<div class="product-setting-page__aside">
|
||||
<div v-if="isCompactLayout" class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
<ElAffix
|
||||
v-else
|
||||
class="product-setting-page__aside-affix"
|
||||
:offset="anchorAffixOffset"
|
||||
:target="layoutScrollTarget"
|
||||
teleported
|
||||
>
|
||||
<div class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||
</div>
|
||||
</ElAffix>
|
||||
</div>
|
||||
|
||||
<div class="product-setting-page__content">
|
||||
<section :id="sectionIdMap['base-info']" class="product-setting-page__section">
|
||||
<SettingBaseInfoCard :base-info="baseInfo" @edit="baseInfoVisible = true" />
|
||||
</section>
|
||||
|
||||
<section :id="sectionIdMap.team" class="product-setting-page__section">
|
||||
<SettingTeamPanel
|
||||
:members="members"
|
||||
:role-options="roleOptions"
|
||||
:loading="memberLoading"
|
||||
:readonly="!canManageTeam"
|
||||
@create="openCreateMember"
|
||||
@edit="openEditMember"
|
||||
@remove="openRemoveMember"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="product-setting-page__section">
|
||||
<SettingLifecyclePanel :lifecycle="lifecycle" @action="openLifecycleAction" />
|
||||
</section>
|
||||
|
||||
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="product-setting-page__section">
|
||||
<SettingDangerZone
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@delete="deleteVisible = true"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
|
||||
<MemberOperateDialog
|
||||
v-model:visible="memberOperateVisible"
|
||||
:mode="memberOperateMode"
|
||||
:member="selectedMember"
|
||||
:current-manager="currentManager"
|
||||
:role-options="roleOptions"
|
||||
:user-options="userOptions"
|
||||
@submit="handleSubmitMemberOperate"
|
||||
/>
|
||||
<MemberRemoveDialog
|
||||
v-model:visible="memberRemoveVisible"
|
||||
:member="selectedMember"
|
||||
@submit="handleSubmitRemoveMember"
|
||||
/>
|
||||
<StatusActionDialog
|
||||
v-model:visible="statusActionVisible"
|
||||
:action="selectedAction"
|
||||
@submit="handleSubmitLifecycleAction"
|
||||
/>
|
||||
<ProductDeleteDialog
|
||||
v-model:visible="deleteVisible"
|
||||
:product-id="currentObjectId"
|
||||
:product-name="baseInfo?.name || currentProduct?.name || ''"
|
||||
@submit="handleSubmitDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-setting-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-affix {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: 100%;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.product-setting-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.product-setting-page__section {
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.product-setting-page__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-setting-page__aside-shell {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
218
src/views/product/setting/modules/base-info-dialog.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import { getProductBaseInfoReadonlyMessage, isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'BaseInfoDialog' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.UpdateProductSettingBaseInfoParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive<Api.Product.UpdateProductSettingBaseInfoParams>({
|
||||
directionCode: '',
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const baseInfoEditable = computed(() => isProductBaseInfoEditable(props.baseInfo?.statusCode));
|
||||
const readonlyMessage = computed(() => getProductBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !baseInfoEditable.value;
|
||||
});
|
||||
|
||||
const directionDisplayName = computed(() => {
|
||||
const directionCode = props.baseInfo?.directionCode;
|
||||
|
||||
if (!directionCode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getDirectionLabel(directionCode, directionCode);
|
||||
});
|
||||
|
||||
const rules = {
|
||||
directionCode: [createRequiredRule('请选择产品方向')],
|
||||
name: [createRequiredRule('请输入产品名称')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
async function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
emit('submit', {
|
||||
directionCode: model.directionCode,
|
||||
name: model.name.trim(),
|
||||
description: model.description?.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value || !props.baseInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.directionCode = props.baseInfo.directionCode || '';
|
||||
model.name = props.baseInfo.name || '';
|
||||
model.description = props.baseInfo.description || '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="编辑基础信息"
|
||||
preset="lg"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品编码">
|
||||
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<ElTooltip
|
||||
content="如需调整产品经理,请到产品内的团队管理处处理。"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
<span class="business-form-label-tip">
|
||||
<icon-fe:question />
|
||||
</span>
|
||||
</ElTooltip>
|
||||
<span>产品经理</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElInput
|
||||
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品名称" prop="name">
|
||||
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.name"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品名称"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品方向" prop="directionCode">
|
||||
<DictSelect
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.directionCode"
|
||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||
filterable
|
||||
placeholder="请选择产品方向"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="directionDisplayName"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未获取到产品方向"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="产品描述">
|
||||
<ElInput
|
||||
v-if="baseInfoEditable"
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="model.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
class="base-info-dialog__readonly-input"
|
||||
placeholder="未填写产品描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
|
||||
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;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
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;
|
||||
cursor: default;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.base-info-dialog__readonly-input .el-input__inner),
|
||||
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
203
src/views/product/setting/modules/member-operate-dialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
|
||||
|
||||
defineOptions({ name: 'MemberOperateDialog' });
|
||||
|
||||
type OperateMode = 'create' | 'edit';
|
||||
|
||||
interface Props {
|
||||
mode: OperateMode;
|
||||
member: Api.Product.ProductMember | null;
|
||||
currentManager: Api.Product.ProductMember | null;
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
}
|
||||
|
||||
interface SubmitPayload {
|
||||
mode: OperateMode;
|
||||
memberId?: string;
|
||||
managerChanged: boolean;
|
||||
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: SubmitPayload): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
interface Model {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
remark: string;
|
||||
reason: string;
|
||||
previousManagerRoleId: string;
|
||||
}
|
||||
|
||||
const model = reactive<Model>({
|
||||
userId: '',
|
||||
roleId: '',
|
||||
remark: '',
|
||||
reason: '',
|
||||
previousManagerRoleId: ''
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
||||
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
|
||||
const showManagerHandover = computed(() => {
|
||||
return (
|
||||
shouldRequireManagerHandover(model.roleId, props.currentManager) &&
|
||||
Boolean(selectedUserId.value) &&
|
||||
selectedUserId.value !== props.currentManager?.userId
|
||||
);
|
||||
});
|
||||
const previousManagerRoleOptions = computed(() =>
|
||||
getPreviousManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
|
||||
);
|
||||
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
||||
roleId: [createRequiredRule('请选择角色')],
|
||||
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原产品经理交接后角色')] : []
|
||||
}) satisfies Record<string, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
async function handleConfirm() {
|
||||
await validate();
|
||||
|
||||
const sharedPayload = {
|
||||
roleId: model.roleId,
|
||||
remark: model.remark.trim() || null,
|
||||
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
|
||||
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
|
||||
};
|
||||
|
||||
if (props.mode === 'create') {
|
||||
emit('submit', {
|
||||
mode: 'create',
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
userId: model.userId,
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
mode: 'edit',
|
||||
memberId: props.member?.id,
|
||||
managerChanged: showManagerHandover.value,
|
||||
payload: {
|
||||
roleId: sharedPayload.roleId,
|
||||
remark: sharedPayload.remark,
|
||||
reason: model.reason.trim() || null,
|
||||
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
|
||||
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
|
||||
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
|
||||
model.reason = '';
|
||||
model.previousManagerRoleId = '';
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<BusinessFormSection title="成员信息">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
||||
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
|
||||
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-else label="成员用户">
|
||||
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="目标角色" prop="roleId">
|
||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="备注">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
|
||||
<ElFormItem label="变更原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入变更原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showManagerHandover" title="产品经理交接">
|
||||
<ElAlert
|
||||
:title="`当前产品经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElFormItem label="原产品经理交接后角色" prop="previousManagerRoleId">
|
||||
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原产品经理交接后角色">
|
||||
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
65
src/views/product/setting/modules/member-remove-dialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'MemberRemoveDialog' });
|
||||
|
||||
interface Props {
|
||||
member: Api.Product.ProductMember | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.InactiveProductMemberParams): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
emit('submit', {
|
||||
reason: model.reason.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
|
||||
<ElAlert
|
||||
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前产品团队中移出吗?`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="移出原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入移出原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
84
src/views/product/setting/modules/product-delete-dialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProductDeleteDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.DeleteProductParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
confirmName: '',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
emit('submit', {
|
||||
id: props.productId,
|
||||
productName: model.confirmName.trim(),
|
||||
reason: model.reason.trim()
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.confirmName = '';
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="删除产品"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
confirm-text="确认删除"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert
|
||||
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="删除确认名称">
|
||||
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="删除原因">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入删除原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
90
src/views/product/setting/modules/setting-anchor-nav.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingAnchorNav' });
|
||||
|
||||
interface SettingAnchorItem {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: readonly SettingAnchorItem[];
|
||||
activeKey: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', key: string): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting-anchor-nav">
|
||||
<div class="setting-anchor-nav__title">设置目录</div>
|
||||
<div class="setting-anchor-nav__list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="setting-anchor-nav__item"
|
||||
:class="{ 'is-active': item.key === activeKey }"
|
||||
@click="emit('select', item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-anchor-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 56%);
|
||||
}
|
||||
|
||||
.setting-anchor-nav__item.is-active {
|
||||
border-color: rgb(13 148 136 / 42%);
|
||||
background-color: rgb(240 253 250 / 98%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
78
src/views/product/setting/modules/setting-base-info-card.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
|
||||
import { isProductBaseInfoEditable } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingBaseInfoCard' });
|
||||
|
||||
interface Props {
|
||||
baseInfo: Api.Product.ProductSettingBaseInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const editDisabled = computed(() => {
|
||||
if (!props.baseInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isProductBaseInfoEditable(props.baseInfo.statusCode);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="editDisabled"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
编辑基础信息
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElDescriptions v-if="baseInfo" :column="2" border>
|
||||
<ElDescriptionsItem label="产品编码">{{ baseInfo.code || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品名称">{{ baseInfo.name || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品方向">
|
||||
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="baseInfo.directionCode" />
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品经理">
|
||||
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="当前状态">
|
||||
<ElTag :type="getProductStatusTagType(baseInfo.statusCode)">
|
||||
{{ getProductStatusLabel(baseInfo.statusCode) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最近状态原因">{{ baseInfo.lastStatusReason || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="产品描述" :span="2">
|
||||
<div class="setting-base-info-card__description">{{ baseInfo.description || '--' }}</div>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElEmpty v-else description="未获取到基础信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-base-info-card__description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
53
src/views/product/setting/modules/setting-danger-zone.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SettingDangerZone' });
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="setting-danger-zone card-wrapper">
|
||||
<div class="setting-danger-zone__content">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
|
||||
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
|
||||
删除后将退出当前产品对象上下文,并返回产品入口页。删除时必须输入当前产品名称
|
||||
<strong>{{ productName || '--' }}</strong>
|
||||
进行二次确认。
|
||||
</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:delete', source: 'object' }"
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('delete')"
|
||||
>
|
||||
删除产品
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-danger-zone {
|
||||
border: 1px solid rgb(254 202 202 / 96%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
|
||||
}
|
||||
|
||||
.setting-danger-zone__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
393
src/views/product/setting/modules/setting-lifecycle-panel.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getProductStatusLabel } from '../../shared/product-master-data';
|
||||
import { getProductLifecycleActionCardMeta, getProductLifecycleStatusSummary } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingLifecyclePanel' });
|
||||
|
||||
interface Props {
|
||||
lifecycle: Api.Product.ProductLifecycleInfo | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: Api.Product.ProductLifecycleAction): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
if (!props.lifecycle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getProductLifecycleStatusSummary(props.lifecycle.statusCode);
|
||||
});
|
||||
|
||||
const actionCards = computed(() =>
|
||||
(props.lifecycle?.availableActions || []).map(action => ({
|
||||
...action,
|
||||
...getProductLifecycleActionCardMeta(action.actionCode)
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="lifecycle">
|
||||
<div class="setting-lifecycle-panel__layout">
|
||||
<section
|
||||
class="setting-lifecycle-panel__hero"
|
||||
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__hero-top">
|
||||
<div class="setting-lifecycle-panel__hero-main">
|
||||
<div class="setting-lifecycle-panel__hero-status-row">
|
||||
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
|
||||
<span class="setting-lifecycle-panel__hero-status-chip">
|
||||
{{ getProductStatusLabel(lifecycle.statusCode) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="setting-lifecycle-panel__hero-desc">
|
||||
{{ statusSummary?.description }}
|
||||
</p>
|
||||
|
||||
<div class="setting-lifecycle-panel__reason-card">
|
||||
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
|
||||
<strong class="setting-lifecycle-panel__reason-value">
|
||||
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="setting-lifecycle-panel__action-panel">
|
||||
<div class="setting-lifecycle-panel__action-head">
|
||||
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
|
||||
</div>
|
||||
|
||||
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
|
||||
<button
|
||||
v-for="action in actionCards"
|
||||
:key="action.actionCode"
|
||||
type="button"
|
||||
class="setting-lifecycle-panel__action-card"
|
||||
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
|
||||
@click="emit('action', action)"
|
||||
>
|
||||
<div class="setting-lifecycle-panel__action-card-top">
|
||||
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
|
||||
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
|
||||
</div>
|
||||
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-else description="未获取到生命周期信息" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-lifecycle-panel__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero,
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 20px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate {
|
||||
border-color: rgb(100 116 139 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-label {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-title {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero-desc {
|
||||
max-width: 560px;
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 82%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-label {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__reason-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-panel {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 18px;
|
||||
background-color: rgb(255 255 255 / 96%);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background-color: currentcolor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-name {
|
||||
color: rgb(15 23 42 / 96%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-desc {
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__empty-tip {
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed rgb(203 213 225 / 92%);
|
||||
border-radius: 16px;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(16 185 129 / 24%);
|
||||
background-color: rgb(236 253 245 / 90%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(245 158 11 / 24%);
|
||||
background-color: rgb(255 247 237 / 94%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(148 163 184 / 28%);
|
||||
background-color: rgb(241 245 249 / 94%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
|
||||
border-color: rgb(244 63 94 / 24%);
|
||||
background-color: rgb(255 241 242 / 94%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald {
|
||||
border-color: rgb(16 185 129 / 22%);
|
||||
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(4 120 87 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(180 83 9 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate {
|
||||
border-color: rgb(148 163 184 / 26%);
|
||||
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(71 85 105 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose {
|
||||
border-color: rgb(244 63 94 / 22%);
|
||||
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||
color: rgb(190 24 93 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald:hover {
|
||||
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber:hover {
|
||||
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate:hover {
|
||||
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose:hover {
|
||||
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
|
||||
color: rgb(6 95 70 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
|
||||
color: rgb(146 64 14 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
|
||||
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
|
||||
color: rgb(159 18 57 / 96%);
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.setting-lifecycle-panel__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.setting-lifecycle-panel__hero-top,
|
||||
.setting-lifecycle-panel__action-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
201
src/views/product/setting/modules/setting-team-panel.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
|
||||
|
||||
defineOptions({ name: 'SettingTeamPanel' });
|
||||
|
||||
interface Props {
|
||||
members: Api.Product.ProductMember[];
|
||||
roleOptions?: Api.SystemManage.RoleSimple[];
|
||||
loading?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', member: Api.Product.ProductMember): void;
|
||||
(e: 'remove', member: Api.Product.ProductMember): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
readonly: false,
|
||||
roleOptions: () => []
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const searchKeyword = ref('');
|
||||
const selectedRoleId = ref('');
|
||||
const teamTableHeight = getProductTeamTableHeight(5);
|
||||
const roleFilterOptions = computed(() => {
|
||||
const roleMap = new Map<string, string>();
|
||||
|
||||
props.roleOptions.forEach(role => {
|
||||
if (!roleMap.has(role.id)) {
|
||||
roleMap.set(role.id, role.name);
|
||||
}
|
||||
});
|
||||
|
||||
return [...roleMap.entries()].map(([value, label]) => ({
|
||||
value,
|
||||
label
|
||||
}));
|
||||
});
|
||||
const filteredMembers = computed(() =>
|
||||
filterProductMembers(props.members, {
|
||||
keyword: searchKeyword.value,
|
||||
roleId: selectedRoleId.value
|
||||
})
|
||||
);
|
||||
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||
|
||||
watch(roleFilterOptions, options => {
|
||||
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
||||
selectedRoleId.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function getMemberStatusLabel(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? '有效' : '失效';
|
||||
}
|
||||
|
||||
function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
||||
return status === 0 ? 'success' : 'info';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper">
|
||||
<template #header>
|
||||
<div class="setting-team-panel__header">
|
||||
<div>
|
||||
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
|
||||
</div>
|
||||
<div class="setting-team-panel__toolbar">
|
||||
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
||||
<ElOption
|
||||
v-for="option in roleFilterOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||
<ElButton
|
||||
v-if="!props.readonly"
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('create')"
|
||||
>
|
||||
新增成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable
|
||||
v-loading="props.loading"
|
||||
:data="filteredMembers"
|
||||
:height="teamTableHeight"
|
||||
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||
border
|
||||
row-key="id"
|
||||
>
|
||||
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
|
||||
<ElTableColumn label="成员状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.joinedTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatProductMemberDate(row.leftTime) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.remark || '--' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!props.readonly" label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="setting-team-panel__actions">
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('edit', row)"
|
||||
>
|
||||
调整角色
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||
link
|
||||
type="danger"
|
||||
:disabled="row.status !== 0 || row.managerFlag"
|
||||
@click="emit('remove', row)"
|
||||
>
|
||||
移出成员
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-team-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.setting-team-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.setting-team-panel__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-team-panel__toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-team-panel__role-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
73
src/views/product/setting/modules/status-action-dialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'StatusActionDialog' });
|
||||
|
||||
interface Props {
|
||||
action: Api.Product.ProductLifecycleAction | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.Product.ChangeProductStatusParams): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
id: '',
|
||||
actionCode: props.action.actionCode,
|
||||
reason: model.reason.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.reason = '';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="action ? `${action.actionName}产品` : '生命周期动作'"
|
||||
preset="sm"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入动作原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
233
src/views/product/setting/shared.ts
Normal file
233
src/views/product/setting/shared.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export interface ProductManagerMemberLike {
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
interface ProductTeamManageContext {
|
||||
buttonCodes: readonly string[];
|
||||
loginUserId: string | null | undefined;
|
||||
currentManagerUserId: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ProductLifecycleStatusSummary {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
caption: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ProductLifecycleActionCardMeta {
|
||||
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const productSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
|
||||
|
||||
export type ProductSettingSectionKey = (typeof productSettingSectionKeys)[number];
|
||||
|
||||
const productSettingSectionAuthCodeMap: Partial<Record<ProductSettingSectionKey, string>> = {
|
||||
lifecycle: 'project:product:status',
|
||||
danger: 'project:product:delete'
|
||||
};
|
||||
|
||||
const productBaseInfoReadonlyMessageMap: Partial<Record<Api.Product.ProductStatusCode, string>> = {
|
||||
paused: '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
archived: '当前产品已归档,基础信息仅支持查看,不可编辑。',
|
||||
abandoned: '当前产品已废弃,基础信息仅支持查看,不可编辑。'
|
||||
};
|
||||
|
||||
const productLifecycleStatusSummaryMap: Record<Api.Product.ProductStatusCode, ProductLifecycleStatusSummary> = {
|
||||
active: {
|
||||
tone: 'emerald',
|
||||
caption: '产品正常服务中',
|
||||
description: '当前可以执行暂停、归档或废弃。'
|
||||
},
|
||||
paused: {
|
||||
tone: 'amber',
|
||||
caption: '产品已暂停推进',
|
||||
description: '条件恢复后可重新启用,也可继续归档或废弃。'
|
||||
},
|
||||
archived: {
|
||||
tone: 'slate',
|
||||
caption: '产品已收口归档',
|
||||
description: '保留历史信息,当前不再开放新的生命周期动作。'
|
||||
},
|
||||
abandoned: {
|
||||
tone: 'rose',
|
||||
caption: '产品已停止建设',
|
||||
description: '产品已结束推进,当前不再开放新的生命周期动作。'
|
||||
}
|
||||
};
|
||||
|
||||
const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionCode, ProductLifecycleActionCardMeta> = {
|
||||
pause: {
|
||||
tone: 'amber',
|
||||
description: '暂停当前产品,后续仍可恢复或归档。'
|
||||
},
|
||||
resume: {
|
||||
tone: 'emerald',
|
||||
description: '恢复启用后,继续推进产品协作。'
|
||||
},
|
||||
archive: {
|
||||
tone: 'slate',
|
||||
description: '收口当前产品,保留历史记录并结束维护。'
|
||||
},
|
||||
abandon: {
|
||||
tone: 'rose',
|
||||
description: '终止当前产品建设,请谨慎确认。'
|
||||
}
|
||||
};
|
||||
|
||||
const productSettingErrorMessageMap: Record<string, string> = {
|
||||
'1008001002': '产品名称已存在,请更换名称',
|
||||
'1008001007': '当前产品状态不允许编辑基础信息',
|
||||
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
|
||||
'1008001013': '请选择原产品经理交接后的角色',
|
||||
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
|
||||
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
|
||||
'1008001004': '当前状态不支持该动作',
|
||||
'1008001005': '当前动作必须填写原因',
|
||||
'1008001006': '删除确认名称与当前产品名称不一致'
|
||||
};
|
||||
|
||||
const productTeamTableHeaderHeight = 40;
|
||||
const productTeamTableRowHeight = 40;
|
||||
|
||||
export function shouldRequireManagerHandover(
|
||||
targetRoleId: string,
|
||||
currentManager: ProductManagerMemberLike | null | undefined
|
||||
) {
|
||||
if (!currentManager?.roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetRoleId === currentManager.roleId;
|
||||
}
|
||||
|
||||
export function getPreviousManagerRoleOptions(roleOptions: Api.SystemManage.RoleSimple[], managerRoleId: string) {
|
||||
return roleOptions.filter(role => role.id !== managerRoleId);
|
||||
}
|
||||
|
||||
export function getProductSettingSectionKeys() {
|
||||
return [...productSettingSectionKeys];
|
||||
}
|
||||
|
||||
export function isProductBaseInfoEditable(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
return status === 'active';
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSections(
|
||||
sectionKeys: readonly ProductSettingSectionKey[],
|
||||
buttonCodes: readonly string[]
|
||||
) {
|
||||
return sectionKeys.filter(sectionKey => {
|
||||
const authCode = productSettingSectionAuthCodeMap[sectionKey];
|
||||
|
||||
if (!authCode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return buttonCodes.includes(authCode);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVisibleProductSettingSectionKey(
|
||||
currentKey: ProductSettingSectionKey | string | null | undefined,
|
||||
visibleSectionKeys: readonly ProductSettingSectionKey[]
|
||||
) {
|
||||
if (!visibleSectionKeys.length) {
|
||||
return 'base-info' satisfies ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
if (currentKey && visibleSectionKeys.includes(currentKey as ProductSettingSectionKey)) {
|
||||
return currentKey as ProductSettingSectionKey;
|
||||
}
|
||||
|
||||
return visibleSectionKeys[0];
|
||||
}
|
||||
|
||||
export function getProductBaseInfoReadonlyMessage(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status || isProductBaseInfoEditable(status)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return productBaseInfoReadonlyMessageMap[status] || '当前产品状态不允许编辑基础信息。';
|
||||
}
|
||||
|
||||
export function getProductLifecycleStatusSummary(status: Api.Product.ProductStatusCode) {
|
||||
return productLifecycleStatusSummaryMap[status];
|
||||
}
|
||||
|
||||
export function formatProductMemberDate(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
||||
const parsedDate = dayjs(normalizedValue);
|
||||
|
||||
if (!parsedDate.isValid()) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return parsedDate.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
export function filterProductMembersByKeyword(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
keyword: string | null | undefined
|
||||
) {
|
||||
return filterProductMembers(members, { keyword });
|
||||
}
|
||||
|
||||
export function filterProductMembers(
|
||||
members: readonly Api.Product.ProductMember[],
|
||||
filters: {
|
||||
keyword?: string | null | undefined;
|
||||
roleId?: string | null | undefined;
|
||||
}
|
||||
) {
|
||||
const normalizedKeyword = String(filters.keyword || '')
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
const normalizedRoleId = String(filters.roleId || '').trim();
|
||||
|
||||
if (!normalizedKeyword && !normalizedRoleId) {
|
||||
return [...members];
|
||||
}
|
||||
|
||||
return members.filter(member => {
|
||||
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
|
||||
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
|
||||
|
||||
return matchesKeyword && matchesRole;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProductTeamTableHeight(visibleRows: number) {
|
||||
const normalizedRows = Math.max(0, visibleRows);
|
||||
|
||||
return productTeamTableHeaderHeight + normalizedRows * productTeamTableRowHeight;
|
||||
}
|
||||
|
||||
export function getProductLifecycleActionCardMeta(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return productLifecycleActionCardMetaMap[actionCode];
|
||||
}
|
||||
|
||||
export function canManageProductTeam(context: ProductTeamManageContext) {
|
||||
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
|
||||
const loginUserId = String(context.loginUserId || '');
|
||||
const currentManagerUserId = String(context.currentManagerUserId || '');
|
||||
|
||||
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return loginUserId === currentManagerUserId;
|
||||
}
|
||||
|
||||
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
|
||||
const normalizedCode = String(code || '');
|
||||
|
||||
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
|
||||
}
|
||||
115
src/views/product/shared/product-context-banner.vue
Normal file
115
src/views/product/shared/product-context-banner.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { getProductStatusLabel, getProductStatusTagType } from './product-master-data';
|
||||
import type { CurrentProductSummary } from './product-context-shared';
|
||||
|
||||
defineOptions({ name: 'ProductContextBanner' });
|
||||
|
||||
interface Props {
|
||||
product: CurrentProductSummary | null;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||
const productStatusCode = computed(() => props.product?.statusCode as Api.Product.ProductStatusCode | undefined);
|
||||
|
||||
const summaryItems = computed(() => {
|
||||
if (!props.product) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: '产品 ID', value: props.product.id || '--' },
|
||||
{ label: '产品编码', value: props.product.code || '--' },
|
||||
{ label: '产品方向', value: getDirectionLabel(props.product.directionCode, '--') },
|
||||
{ label: '产品经理', value: props.product.managerUserId || '--' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-context-banner card-wrapper">
|
||||
<template v-if="product">
|
||||
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-12px flex flex-wrap items-center gap-10px">
|
||||
<span class="product-context-banner__code">{{ product.code }}</span>
|
||||
<ElTag :type="getProductStatusTagType(productStatusCode!)" effect="light" round>
|
||||
{{ getProductStatusLabel(productStatusCode!) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="mb-10px flex flex-wrap items-center gap-12px">
|
||||
<h2 class="text-24px text-[#0f172a] font-700">{{ product.name }}</h2>
|
||||
<span class="text-14px text-[#64748b]">{{ caption }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
|
||||
<span>对象 ID:{{ product.id || '--' }}</span>
|
||||
<span>方向:{{ getDirectionLabel(product.directionCode, '--') }}</span>
|
||||
<span>产品经理:{{ product.managerUserId || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-context-banner__stats">
|
||||
<div v-for="item in summaryItems" :key="item.label" class="product-context-banner__stat-card">
|
||||
<span class="product-context-banner__stat-label">{{ item.label }}</span>
|
||||
<strong class="product-context-banner__stat-value">{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty v-else description="未获取到当前产品上下文" :image-size="84" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-context-banner {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(226 232 240 / 88%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
|
||||
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
|
||||
}
|
||||
|
||||
.product-context-banner__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background-color: rgb(15 23 42 / 88%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.product-context-banner__stats {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: repeat(2, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.product-context-banner__stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgb(148 163 184 / 18%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 72%);
|
||||
}
|
||||
|
||||
.product-context-banner__stat-label {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-context-banner__stat-value {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
43
src/views/product/shared/product-context-shared.ts
Normal file
43
src/views/product/shared/product-context-shared.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface CurrentProductSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
directionCode: string;
|
||||
name: string;
|
||||
managerUserId: string;
|
||||
statusCode: string;
|
||||
}
|
||||
|
||||
export function resolveObjectIdFromQuery(
|
||||
routeObjectId: string | null | Array<string | null> | undefined,
|
||||
fallbackObjectId: string
|
||||
) {
|
||||
if (Array.isArray(routeObjectId)) {
|
||||
return String(routeObjectId[0] || fallbackObjectId || '');
|
||||
}
|
||||
|
||||
if (routeObjectId === null || routeObjectId === undefined || routeObjectId === '') {
|
||||
return fallbackObjectId;
|
||||
}
|
||||
|
||||
return String(routeObjectId);
|
||||
}
|
||||
|
||||
export function normalizeCurrentProductSummary(
|
||||
objectSummary: App.ObjectContext.Summary | null | undefined,
|
||||
objectName: string
|
||||
): CurrentProductSummary | null {
|
||||
const currentProduct = objectSummary?.currentProduct;
|
||||
|
||||
if (!currentProduct || typeof currentProduct !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String((currentProduct as Record<string, unknown>).id || ''),
|
||||
code: String((currentProduct as Record<string, unknown>).code || ''),
|
||||
directionCode: String((currentProduct as Record<string, unknown>).directionCode || ''),
|
||||
name: String((currentProduct as Record<string, unknown>).name || objectName || ''),
|
||||
managerUserId: String((currentProduct as Record<string, unknown>).managerUserId || ''),
|
||||
statusCode: String((currentProduct as Record<string, unknown>).statusCode || '')
|
||||
};
|
||||
}
|
||||
68
src/views/product/shared/product-master-data.ts
Normal file
68
src/views/product/shared/product-master-data.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const productStatusRecord: Record<Api.Product.ProductStatusCode, string> = {
|
||||
active: '启用',
|
||||
paused: '暂停',
|
||||
archived: '归档',
|
||||
abandoned: '废弃'
|
||||
};
|
||||
|
||||
export const productStatusOptions = transformRecordToOption(productStatusRecord);
|
||||
|
||||
export const productStatusActionRecord: Record<Api.Product.ProductStatusActionCode, string> = {
|
||||
pause: '暂停产品',
|
||||
resume: '恢复产品',
|
||||
archive: '归档产品',
|
||||
abandon: '废弃产品'
|
||||
};
|
||||
|
||||
export function getProductStatusLabel(status: Api.Product.ProductStatusCode) {
|
||||
return productStatusRecord[status];
|
||||
}
|
||||
|
||||
export function getProductStatusTagType(status: Api.Product.ProductStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Product.ProductStatusCode, UI.ThemeColor> = {
|
||||
active: 'success',
|
||||
paused: 'warning',
|
||||
archived: 'info',
|
||||
abandoned: 'danger'
|
||||
};
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
}
|
||||
|
||||
export function isProductEditable(status: Api.Product.ProductStatusCode) {
|
||||
return status === 'active' || status === 'paused';
|
||||
}
|
||||
|
||||
export function isProductEditLimited(status: Api.Product.ProductStatusCode) {
|
||||
return status === 'paused';
|
||||
}
|
||||
|
||||
export function getAllowedProductStatusActions(
|
||||
status: Api.Product.ProductStatusCode
|
||||
): Api.Product.ProductStatusActionCode[] {
|
||||
const actionMap: Record<Api.Product.ProductStatusCode, Api.Product.ProductStatusActionCode[]> = {
|
||||
active: ['pause', 'archive', 'abandon'],
|
||||
paused: ['resume', 'archive', 'abandon'],
|
||||
archived: [],
|
||||
abandoned: []
|
||||
};
|
||||
|
||||
return actionMap[status];
|
||||
}
|
||||
|
||||
export function getProductStatusActionLabel(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return productStatusActionRecord[actionCode];
|
||||
}
|
||||
|
||||
export function getProductStatusActionOptions(status: Api.Product.ProductStatusCode) {
|
||||
return getAllowedProductStatusActions(status).map(actionCode => ({
|
||||
value: actionCode,
|
||||
label: getProductStatusActionLabel(actionCode)
|
||||
}));
|
||||
}
|
||||
|
||||
export function isProductActionReasonRequired(actionCode: Api.Product.ProductStatusActionCode) {
|
||||
return actionCode !== 'resume';
|
||||
}
|
||||
23
src/views/product/shared/use-current-product.ts
Normal file
23
src/views/product/shared/use-current-product.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { normalizeCurrentProductSummary, resolveObjectIdFromQuery } from './product-context-shared';
|
||||
|
||||
export function useCurrentProduct() {
|
||||
const route = useRoute();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
const currentObjectId = computed(() => {
|
||||
return resolveObjectIdFromQuery(route.query[OBJECT_CONTEXT_QUERY_KEY], objectContextStore.objectId);
|
||||
});
|
||||
|
||||
const currentProduct = computed(() =>
|
||||
normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName)
|
||||
);
|
||||
|
||||
return {
|
||||
currentObjectId,
|
||||
currentProduct
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { menuRouteKindRecord, menuTypeRecord } from '@/constants/business';
|
||||
import {
|
||||
commonStatusRecord,
|
||||
menuRouteKindRecord,
|
||||
menuTypeRecord,
|
||||
objectTypeRecord,
|
||||
scopeTypeRecord
|
||||
} from '@/constants/business';
|
||||
import { fetchBatchDeleteMenu, fetchDeleteMenu, fetchGetMenuList } from '@/service/api';
|
||||
import { useUITable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import MenuContextPanel from './modules/menu-context-panel.vue';
|
||||
import MenuIconCell from './modules/menu-icon-cell';
|
||||
import MenuOperateDialog, { type OperateType } from './modules/menu-operate-dialog.vue';
|
||||
import MenuOperateCell from './modules/menu-operate-cell';
|
||||
@@ -32,6 +39,28 @@ function getMenuTypeTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getStatusTagType(status: Api.SystemManage.CommonStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
return $t(commonStatusRecord[status]);
|
||||
}
|
||||
|
||||
function getMenuTypeLabel(type: Api.SystemManage.MenuType, currentScopeType: Api.SystemManage.ScopeType) {
|
||||
if (currentScopeType === 'object') {
|
||||
if (type === 2) {
|
||||
return $t('page.system.menu.type.navigation');
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
return $t('page.system.menu.type.actionButton');
|
||||
}
|
||||
}
|
||||
|
||||
return $t(menuTypeRecord[type]);
|
||||
}
|
||||
|
||||
function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
if (!routeKind) {
|
||||
return '--';
|
||||
@@ -42,9 +71,30 @@ function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const flatMenuList = ref<Api.SystemManage.Menu[]>([]);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
api: () => fetchGetMenuList(searchParams),
|
||||
api: () =>
|
||||
fetchGetMenuList({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
flatMenuList.value = response.data;
|
||||
@@ -56,13 +106,20 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 240, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'type',
|
||||
label: $t('page.system.menu.menuType'),
|
||||
width: 96,
|
||||
width: 108,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{$t(menuTypeRecord[row.type])}</ElTag>
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{getMenuTypeLabel(row.type, scopeType.value)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.menu.menuStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
@@ -73,6 +130,7 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
return <MenuIconCell icon={row.icon ?? ''} />;
|
||||
}
|
||||
},
|
||||
{ prop: 'sort', label: $t('page.system.menu.order'), width: 88, align: 'center' },
|
||||
{ prop: 'permission', label: $t('page.system.menu.permission'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'path', label: $t('page.system.menu.routePath'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
@@ -99,11 +157,23 @@ const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
|
||||
const operateType = ref<OperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Menu | null>(null);
|
||||
const checkedRowKeys = ref<number[]>([]);
|
||||
const checkedRowKeys = ref<string[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const tableRenderKey = ref(0);
|
||||
|
||||
const allMenus = computed(() => flatMenuList.value);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.menu.objectResourceTitle' : 'page.system.menu.globalResourceTitle');
|
||||
});
|
||||
const expandedRowKeys = computed(() => {
|
||||
const firstRootMenu = data.value[0];
|
||||
|
||||
@@ -140,7 +210,7 @@ function openEdit(item: Api.SystemManage.Menu) {
|
||||
openModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
async function handleDelete(id: string) {
|
||||
const { error } = await fetchDeleteMenu(id);
|
||||
|
||||
if (error) {
|
||||
@@ -193,27 +263,83 @@ async function handleSubmitted() {
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<MenuContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="flatMenuList.length"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden" body-class="menu-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.menu.title') }}</p>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
<div class="menu-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">{{ currentContextTagLabel }}</ElTag>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading"
|
||||
@add="openAdd"
|
||||
@delete="handleBatchDelete"
|
||||
@refresh="reloadTable"
|
||||
/>
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDelete">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -238,6 +364,8 @@ async function handleSubmitted() {
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:all-menus="allMenus"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
@@ -249,4 +377,18 @@ async function handleSubmitted() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
249
src/views/system/menu/modules/menu-context-panel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuContextPanel' });
|
||||
|
||||
interface Props {
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
const scopeType = defineModel<Api.SystemManage.ScopeType>('scopeType', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const objectType = defineModel<Api.SystemManage.ObjectType | undefined>('objectType');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: $t(scopeTypeRecord.global), value: 'global' satisfies Api.SystemManage.ScopeType },
|
||||
{ label: $t(scopeTypeRecord.object), value: 'object' satisfies Api.SystemManage.ScopeType }
|
||||
]);
|
||||
|
||||
const objectTypeOptions = computed(() => [
|
||||
{ label: $t(objectTypeRecord.product), value: 'product' satisfies Api.SystemManage.ObjectType },
|
||||
{ label: $t(objectTypeRecord.project), value: 'project' satisfies Api.SystemManage.ObjectType }
|
||||
]);
|
||||
|
||||
const currentContextLabel = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t(scopeTypeRecord.global);
|
||||
}
|
||||
|
||||
if (!objectType.value) {
|
||||
return `${$t(scopeTypeRecord.object)} / --`;
|
||||
}
|
||||
|
||||
return `${$t(scopeTypeRecord.object)} / ${$t(objectTypeRecord[objectType.value])}`;
|
||||
});
|
||||
|
||||
const currentScopeSummary = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t('page.system.menu.globalResourceSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.menu.objectResourceSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.menu.objectResourceSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.menu.objectResourceSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="menu-context-panel" body-class="menu-context-panel__body">
|
||||
<div v-loading="props.loading" class="menu-context-panel__layout">
|
||||
<div class="menu-context-panel__controls">
|
||||
<div class="menu-context-panel__field menu-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="menu-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="menu-context-panel__field menu-context-panel__field--inline">
|
||||
<span class="menu-context-panel__field-label menu-context-panel__field-label--inline">
|
||||
{{ $t('page.system.menu.objectType') }}
|
||||
</span>
|
||||
<ElSelect v-model="objectType" class="w-full" :placeholder="$t('page.system.menu.objectTypePlaceholder')">
|
||||
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info">
|
||||
<div class="menu-context-panel__info-main">
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="menu-context-panel__info-item">
|
||||
<span class="menu-context-panel__info-label">{{ $t('page.system.menu.currentResourceCount') }}</span>
|
||||
<strong class="menu-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="menu-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.menu-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.menu-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.menu-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.menu-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.menu-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.menu-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.menu-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { menuRouteKindOptions, menuTypeOptions } from '@/constants/business';
|
||||
import type { ElegantConstRoute } from '@elegant-router/types';
|
||||
import { commonStatusOptions, menuRouteKindOptions } from '@/constants/business';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { fetchCreateMenu, fetchGetMenu, fetchUpdateMenu } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { createStaticRoutes } from '@/router/routes';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
@@ -18,6 +21,8 @@ interface Props {
|
||||
operateType: OperateType;
|
||||
rowData?: Api.SystemManage.Menu | null;
|
||||
allMenus: Api.SystemManage.Menu[];
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -32,8 +37,6 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
type PageResourceItem = (typeof frontendPageResourceManifest.items)[number];
|
||||
|
||||
type Model = Api.SystemManage.SaveMenuParams & {
|
||||
pageResourcePath: string;
|
||||
iframeUrl: string;
|
||||
@@ -48,12 +51,21 @@ type RuleFormItem = {
|
||||
};
|
||||
|
||||
type ParentTreeOption = {
|
||||
value: number;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
children?: ParentTreeOption[];
|
||||
};
|
||||
|
||||
type RouteBindingItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
title: string;
|
||||
keepAlive: boolean;
|
||||
props: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DIRECTORY_COMPONENT = frontendPageResourceManifest.rules.directoryComponent;
|
||||
const IFRAME_COMPONENT = 'view.iframe-page';
|
||||
|
||||
@@ -62,7 +74,16 @@ const pageResourceItems = frontendPageResourceManifest.items
|
||||
.slice()
|
||||
.sort((prev, next) => prev.path.localeCompare(next.path));
|
||||
|
||||
const pageResourceMap = new Map(pageResourceItems.map(item => [item.path, item]));
|
||||
const globalRouteBindingItems: RouteBindingItem[] = pageResourceItems.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
component: item.component,
|
||||
title: item.title || item.name,
|
||||
keepAlive: Boolean(item.keepAlive),
|
||||
props: (item.props as Record<string, unknown> | null) ?? null
|
||||
}));
|
||||
|
||||
const staticAuthRoutes = createStaticRoutes().authRoutes;
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
@@ -73,6 +94,7 @@ const initializingModel = ref(false);
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const isAddChild = computed(() => props.operateType === 'addChild');
|
||||
const isObjectScope = computed(() => props.scopeType === 'object');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<OperateType, string> = {
|
||||
@@ -84,15 +106,32 @@ const title = computed(() => {
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
const dialogWidth = '780px';
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
permission: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
type: 2,
|
||||
sort: 0,
|
||||
parentId: 0,
|
||||
parentId: '0',
|
||||
path: '',
|
||||
icon: '',
|
||||
component: '',
|
||||
@@ -171,6 +210,54 @@ function buildComponentNameFromFullPath(fullPath?: string | null) {
|
||||
.join('_');
|
||||
}
|
||||
|
||||
function isPathMatchedByPrefix(path: string, prefix: string) {
|
||||
const normalizedPath = toAbsoluteRoutePath(path);
|
||||
const normalizedPrefix = toAbsoluteRoutePath(prefix);
|
||||
|
||||
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
|
||||
}
|
||||
|
||||
function collectObjectRouteBindingItems(
|
||||
routes: ElegantConstRoute[],
|
||||
config: App.ObjectContext.DomainConfig
|
||||
): RouteBindingItem[] {
|
||||
return routes.flatMap(route => {
|
||||
if (route.children?.length) {
|
||||
return collectObjectRouteBindingItems(route.children, config);
|
||||
}
|
||||
|
||||
const routePath = toAbsoluteRoutePath(route.path);
|
||||
|
||||
if (!routePath || route.name === config.entryRouteKey || routePath === config.entryRoutePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(routePath, prefix))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const component = String(route.component ?? '').trim();
|
||||
|
||||
if (!component.includes('view.')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: String(route.name || routePath),
|
||||
path: routePath,
|
||||
component,
|
||||
title: route.meta?.i18nKey ? $t(route.meta.i18nKey) : String(route.meta?.title || route.name || routePath),
|
||||
keepAlive: Boolean(route.meta?.keepAlive),
|
||||
props:
|
||||
route.props && typeof route.props === 'object' && !Array.isArray(route.props)
|
||||
? (route.props as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseRoutePropsJson(value?: string | null) {
|
||||
const text = String(value ?? '').trim();
|
||||
|
||||
@@ -217,13 +304,13 @@ function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function getMenuFullPath(menuId: number) {
|
||||
if (!menuId) {
|
||||
function getMenuFullPath(menuId: string) {
|
||||
if (!menuId || menuId === '0') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const menuMap = new Map(props.allMenus.map(item => [item.id, item]));
|
||||
const visitedIds = new Set<number>();
|
||||
const visitedIds = new Set<string>();
|
||||
const pathSegments: string[] = [];
|
||||
let currentMenu = menuMap.get(menuId);
|
||||
|
||||
@@ -236,7 +323,7 @@ function getMenuFullPath(menuId: number) {
|
||||
pathSegments.unshift(currentPath);
|
||||
}
|
||||
|
||||
if (!currentMenu.parentId) {
|
||||
if (currentMenu.parentId === '0') {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -250,11 +337,18 @@ function getMenuFullPathByData(data: Pick<Api.SystemManage.Menu, 'parentId' | 'p
|
||||
return joinRoutePaths(getMenuFullPath(data.parentId), data.path);
|
||||
}
|
||||
|
||||
function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
function resolveRouteBindingPath(data: Api.SystemManage.Menu) {
|
||||
const viewComponent = extractViewComponent(data.component);
|
||||
const objectDomainConfig = props.objectType
|
||||
? objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null
|
||||
: null;
|
||||
const candidateItems =
|
||||
isObjectScope.value && objectDomainConfig
|
||||
? collectObjectRouteBindingItems(staticAuthRoutes, objectDomainConfig)
|
||||
: globalRouteBindingItems;
|
||||
|
||||
if (viewComponent) {
|
||||
const matchedByComponent = pageResourceItems.find(item => item.component === viewComponent);
|
||||
const matchedByComponent = candidateItems.find(item => item.component === viewComponent);
|
||||
|
||||
if (matchedByComponent) {
|
||||
return matchedByComponent.path;
|
||||
@@ -263,16 +357,81 @@ function resolvePageResourcePath(data: Api.SystemManage.Menu) {
|
||||
|
||||
const fullPath = getMenuFullPathByData(data);
|
||||
|
||||
return pageResourceItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
return candidateItems.find(item => item.path === fullPath)?.path ?? '';
|
||||
}
|
||||
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? 0);
|
||||
const currentMenuId = computed(() => props.rowData?.id ?? '0');
|
||||
const currentParentFullPath = computed(() => getMenuFullPath(model.value.parentId));
|
||||
const isButton = computed(() => model.value.type === 3);
|
||||
const isMenu = computed(() => model.value.type === 2);
|
||||
const currentObjectDomainConfig = computed(() => {
|
||||
if (!props.objectType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return objectContextDomainConfigs.find(config => config.objectType === props.objectType) || null;
|
||||
});
|
||||
|
||||
const objectRouteBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
if (!isObjectScope.value || !currentObjectDomainConfig.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectObjectRouteBindingItems(staticAuthRoutes, currentObjectDomainConfig.value);
|
||||
});
|
||||
|
||||
const routeBindingItems = computed<RouteBindingItem[]>(() => {
|
||||
return isObjectScope.value ? objectRouteBindingItems.value : globalRouteBindingItems;
|
||||
});
|
||||
|
||||
const routeBindingMap = computed(() => new Map(routeBindingItems.value.map(item => [item.path, item])));
|
||||
const routeBindingOptions = computed(() =>
|
||||
routeBindingItems.value.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedRouteBinding = computed(() => routeBindingMap.value.get(model.value.pageResourcePath) ?? null);
|
||||
const routeBindingFieldLabel = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.boundRoute') : $t('page.system.menu.pageResource')
|
||||
);
|
||||
const routeBindingFieldPlaceholder = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
);
|
||||
const routeBindingFieldTip = computed(() =>
|
||||
isObjectScope.value ? $t('page.system.menu.tips.boundRoute') : $t('page.system.menu.tips.pageResource')
|
||||
);
|
||||
|
||||
const menuTypeRadioOptions = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: false },
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.menu', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.button', disabled: false }
|
||||
];
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ value: 2 as Api.SystemManage.MenuType, label: 'page.system.menu.type.navigation', disabled: false },
|
||||
{ value: 3 as Api.SystemManage.MenuType, label: 'page.system.menu.type.actionButton', disabled: false }
|
||||
];
|
||||
|
||||
if (isEdit.value && model.value.type === 1) {
|
||||
return [
|
||||
{ value: 1 as Api.SystemManage.MenuType, label: 'page.system.menu.type.directory', disabled: true },
|
||||
...options
|
||||
];
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const showRouteFields = computed(() => !isButton.value);
|
||||
const showRouteSection = computed(() => showRouteFields.value);
|
||||
const showPermissionField = computed(() => isButton.value);
|
||||
const showIconField = computed(() => showRouteFields.value);
|
||||
const showRouteKindField = computed(() => showRouteFields.value && !isObjectScope.value);
|
||||
|
||||
const isDirectoryRoute = computed(() => model.value.routeKind === 'dir');
|
||||
const isViewRoute = computed(() => model.value.routeKind === 'view');
|
||||
@@ -296,7 +455,9 @@ const showExternalUrlField = computed(() => isExternalRoute.value);
|
||||
const showRedirectTargetField = computed(() => isRedirectRoute.value);
|
||||
const showReadonlyRouteProps = computed(() => isIframeRoute.value);
|
||||
const showRoutePropsEditor = computed(() => isSingleRoute.value);
|
||||
const canKeepAlive = computed(() => isMenu.value && !isExternalRoute.value && !isRedirectRoute.value);
|
||||
const canKeepAlive = computed(
|
||||
() => !isObjectScope.value && isMenu.value && !isExternalRoute.value && !isRedirectRoute.value
|
||||
);
|
||||
const showDisplaySection = computed(() => canKeepAlive.value);
|
||||
|
||||
const keepAliveSwitch = computed({
|
||||
@@ -314,6 +475,10 @@ const iconFieldValue = computed({
|
||||
});
|
||||
|
||||
const routeKindSelectOptions = computed(() => {
|
||||
if (isObjectScope.value && model.value.type === 2) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'view');
|
||||
}
|
||||
|
||||
if (model.value.type === 1) {
|
||||
return menuRouteKindOptions.filter(item => item.value === 'dir');
|
||||
}
|
||||
@@ -338,14 +503,11 @@ const routeKindTipItems = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const pageResourceOptions = pageResourceItems.map(item => ({
|
||||
value: item.path,
|
||||
label: `${item.title || item.name} (${item.path})`
|
||||
}));
|
||||
|
||||
const selectedPageResource = computed(() => pageResourceMap.get(model.value.pageResourcePath) ?? null);
|
||||
|
||||
const displayRoutePath = computed(() => {
|
||||
if (isObjectScope.value && isMenu.value) {
|
||||
return selectedRouteBinding.value?.path || toAbsoluteRoutePath(model.value.path);
|
||||
}
|
||||
|
||||
return joinRoutePaths(currentParentFullPath.value, model.value.path);
|
||||
});
|
||||
|
||||
@@ -354,6 +516,24 @@ const hasCompatibleViewRouteData = computed(() =>
|
||||
);
|
||||
|
||||
const rules = computed(() => {
|
||||
const permissionRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.permission'),
|
||||
trigger: 'blur',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPermissionField.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(value ?? '').trim()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.permission')));
|
||||
}
|
||||
};
|
||||
|
||||
const pathRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.path'),
|
||||
trigger: 'blur',
|
||||
@@ -373,7 +553,7 @@ const rules = computed(() => {
|
||||
};
|
||||
|
||||
const pageResourceRule: RuleFormItem = {
|
||||
message: $t('page.system.menu.form.pageResource'),
|
||||
message: isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource'),
|
||||
trigger: 'change',
|
||||
validator: (_, value, callback) => {
|
||||
if (!showPageResourceField.value) {
|
||||
@@ -387,7 +567,11 @@ const rules = computed(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error($t('page.system.menu.form.pageResource')));
|
||||
callback(
|
||||
new Error(
|
||||
isObjectScope.value ? $t('page.system.menu.form.boundRoute') : $t('page.system.menu.form.pageResource')
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -514,7 +698,9 @@ const rules = computed(() => {
|
||||
name: createRequiredRule($t('page.system.menu.form.menuName')),
|
||||
type: createRequiredRule($t('page.system.menu.form.menuType')),
|
||||
parentId: createRequiredRule($t('page.system.menu.form.parentId')),
|
||||
permission: permissionRule,
|
||||
sort: createRequiredRule($t('page.system.menu.form.sort')),
|
||||
status: createRequiredRule($t('page.system.menu.form.menuStatus')),
|
||||
path: pathRule,
|
||||
pageResourcePath: pageResourceRule,
|
||||
component: componentRule,
|
||||
@@ -528,10 +714,24 @@ const rules = computed(() => {
|
||||
|
||||
const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
const menuTree = buildMenuTree(props.allMenus);
|
||||
const descendantIds = currentMenuId.value ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<number>([currentMenuId.value, ...descendantIds].filter(Boolean));
|
||||
const descendantIds = currentMenuId.value !== '0' ? collectDescendantIds(menuTree, currentMenuId.value) : [];
|
||||
const disabledIds = new Set<string>([currentMenuId.value, ...descendantIds].filter(id => id !== '0'));
|
||||
|
||||
const availableMenus = props.allMenus.filter(item => item.type !== 3);
|
||||
const availableMenus = props.allMenus.filter(item => {
|
||||
if (item.type === 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isObjectScope.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMenu.value) {
|
||||
return item.type === 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const availableMenuTree = buildMenuTree(availableMenus);
|
||||
|
||||
function mapTreeOptions(nodes: Api.SystemManage.Menu[]): ParentTreeOption[] {
|
||||
@@ -545,7 +745,7 @@ const parentTreeOptions = computed<ParentTreeOption[]>(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
value: 0,
|
||||
value: '0',
|
||||
label: $t('page.system.menu.topLevel'),
|
||||
children: mapTreeOptions(availableMenuTree)
|
||||
}
|
||||
@@ -572,6 +772,19 @@ function clearFormValidation() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function clearRouteFields() {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
}
|
||||
|
||||
function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
if (type === 1) {
|
||||
model.value.permission = '';
|
||||
@@ -579,28 +792,23 @@ function applyMenuTypePreset(type: Api.SystemManage.MenuType) {
|
||||
model.value.keepAlive = false;
|
||||
}
|
||||
|
||||
if (type === 2 && (!model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
if (type === 2 && (isObjectScope.value || !model.value.routeKind || model.value.routeKind === 'dir')) {
|
||||
model.value.permission = '';
|
||||
model.value.routeKind = 'view';
|
||||
}
|
||||
|
||||
if (type === 3) {
|
||||
model.value.path = '';
|
||||
model.value.icon = '';
|
||||
model.value.component = '';
|
||||
model.value.componentName = '';
|
||||
model.value.routeKind = null;
|
||||
model.value.routePropsJson = '';
|
||||
model.value.pageResourcePath = '';
|
||||
model.value.iframeUrl = '';
|
||||
model.value.externalUrl = '';
|
||||
model.value.redirectTarget = '';
|
||||
clearRouteFields();
|
||||
model.value.keepAlive = false;
|
||||
model.value.alwaysShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultRouteKind(type: Api.SystemManage.MenuType) {
|
||||
if (isObjectScope.value && type === 2) {
|
||||
return 'view';
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
return 'dir';
|
||||
}
|
||||
@@ -621,22 +829,23 @@ function syncDirectoryRouteFields() {
|
||||
}
|
||||
|
||||
function syncViewRouteFields() {
|
||||
const pageResource = selectedPageResource.value;
|
||||
const routeBinding = selectedRouteBinding.value;
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRelativePath =
|
||||
getRelativeRoutePath(pageResource.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(pageResource.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
const nextPath = isObjectScope.value
|
||||
? toAbsoluteRoutePath(routeBinding.path)
|
||||
: getRelativeRoutePath(routeBinding.path, currentParentFullPath.value) ||
|
||||
normalizeRoutePart(model.value.path) ||
|
||||
normalizeRoutePart(routeBinding.path).split('/').filter(Boolean).at(-1) ||
|
||||
'';
|
||||
|
||||
model.value.path = nextRelativePath;
|
||||
model.value.component = pageResource.component;
|
||||
model.value.componentName = pageResource.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(pageResource.props as Record<string, unknown> | null);
|
||||
model.value.path = nextPath;
|
||||
model.value.component = routeBinding.component;
|
||||
model.value.componentName = routeBinding.name;
|
||||
model.value.routePropsJson = stringifyRouteProps(routeBinding.props);
|
||||
}
|
||||
|
||||
function syncIframeRouteFields() {
|
||||
@@ -724,8 +933,8 @@ function syncCurrentRouteFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyPageResourceMeta(pageResource: PageResourceItem) {
|
||||
model.value.keepAlive = Boolean(pageResource.keepAlive);
|
||||
function applyRouteBindingMeta(routeBinding: RouteBindingItem) {
|
||||
model.value.keepAlive = Boolean(routeBinding.keepAlive);
|
||||
}
|
||||
|
||||
function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
@@ -734,6 +943,8 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
return {
|
||||
name: data.name,
|
||||
permission: data.permission ?? '',
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
type: data.type,
|
||||
sort: data.sort,
|
||||
parentId: data.parentId,
|
||||
@@ -747,7 +958,7 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
visible: data.visible ?? true,
|
||||
keepAlive: data.keepAlive ?? false,
|
||||
alwaysShow: data.alwaysShow ?? false,
|
||||
pageResourcePath: data.routeKind === 'view' ? resolvePageResourcePath(data) : '',
|
||||
pageResourcePath: data.routeKind === 'view' ? resolveRouteBindingPath(data) : '',
|
||||
iframeUrl: data.routeKind === 'iframe' ? getRoutePropText(routeProps, 'url') : '',
|
||||
externalUrl: data.routeKind === 'external' ? getRoutePropText(routeProps, 'url') : '',
|
||||
redirectTarget: data.routeKind === 'redirect' ? getRoutePropText(routeProps, 'redirect') : ''
|
||||
@@ -755,28 +966,41 @@ function mapMenuDetailToModel(data: Api.SystemManage.Menu): Model {
|
||||
}
|
||||
|
||||
function getSubmitData(): Api.SystemManage.SaveMenuParams {
|
||||
const scopeData = getCurrentScopeParams();
|
||||
let submitPath: string | null = null;
|
||||
|
||||
if (showRouteFields.value) {
|
||||
submitPath =
|
||||
isObjectScope.value && isMenu.value
|
||||
? getNullableText(toAbsoluteRoutePath(model.value.path))
|
||||
: getNullableText(model.value.path);
|
||||
}
|
||||
|
||||
return {
|
||||
...scopeData,
|
||||
name: model.value.name.trim(),
|
||||
type: model.value.type,
|
||||
sort: model.value.sort,
|
||||
parentId: model.value.parentId,
|
||||
status: model.value.status,
|
||||
permission: isButton.value ? getNullableText(model.value.permission) : null,
|
||||
path: showRouteFields.value ? getNullableText(model.value.path) : null,
|
||||
path: submitPath,
|
||||
icon: showIconField.value ? getNullableText(model.value.icon) : null,
|
||||
component: showRouteFields.value ? getNullableText(model.value.component) : null,
|
||||
componentName: showRouteFields.value ? getNullableText(model.value.componentName) : null,
|
||||
routeKind: showRouteFields.value ? (model.value.routeKind ?? null) : null,
|
||||
routePropsJson: showRouteFields.value ? getNullableText(model.value.routePropsJson) : null,
|
||||
visible: isButton.value ? false : Boolean(model.value.visible),
|
||||
keepAlive: canKeepAlive.value ? Boolean(model.value.keepAlive) : false,
|
||||
keepAlive: showRouteFields.value ? Boolean(model.value.keepAlive) : false,
|
||||
alwaysShow: false
|
||||
};
|
||||
}
|
||||
|
||||
async function submitMenu(data: Api.SystemManage.SaveMenuParams) {
|
||||
if (isEdit.value && props.rowData) {
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...data });
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = data;
|
||||
|
||||
return fetchUpdateMenu({ id: props.rowData.id, ...updateData });
|
||||
}
|
||||
|
||||
return fetchCreateMenu(data);
|
||||
@@ -788,11 +1012,16 @@ async function initModel() {
|
||||
|
||||
if (isAddChild.value && props.rowData) {
|
||||
model.value.parentId = props.rowData.id;
|
||||
|
||||
if (isObjectScope.value && props.rowData.type === 2) {
|
||||
model.value.type = 3;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEdit.value || !props.rowData) {
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
initializingModel.value = false;
|
||||
@@ -807,6 +1036,7 @@ async function initModel() {
|
||||
|
||||
if (!error) {
|
||||
model.value = mapMenuDetailToModel(data);
|
||||
applyMenuTypePreset(model.value.type);
|
||||
syncCurrentRouteFields();
|
||||
}
|
||||
|
||||
@@ -860,7 +1090,6 @@ watch(
|
||||
() => model.value.parentId,
|
||||
async () => {
|
||||
syncCurrentRouteFields();
|
||||
|
||||
await nextTick();
|
||||
clearFormValidation();
|
||||
}
|
||||
@@ -882,14 +1111,14 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
const pageResource = pageResourceMap.get(value);
|
||||
const routeBinding = routeBindingMap.value.get(value);
|
||||
|
||||
if (!pageResource) {
|
||||
if (!routeBinding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initializingModel.value) {
|
||||
applyPageResourceMeta(pageResource);
|
||||
applyRouteBindingMeta(routeBinding);
|
||||
}
|
||||
|
||||
syncViewRouteFields();
|
||||
@@ -934,7 +1163,7 @@ watch(visible, value => {
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="780px"
|
||||
:width="dialogWidth"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
@@ -945,7 +1174,12 @@ watch(visible, value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuType')" prop="type">
|
||||
<ElRadioGroup v-model="model.type" class="business-form-radio-group" :disabled="isEdit">
|
||||
<ElRadio v-for="{ label, value } in menuTypeOptions" :key="value" :value="value">
|
||||
<ElRadio
|
||||
v-for="{ label, value, disabled } in menuTypeRadioOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
@@ -987,12 +1221,21 @@ watch(visible, value => {
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection v-if="showRouteFields" :title="$t('page.system.menu.sections.route')">
|
||||
<BusinessFormSection v-if="showRouteSection" :title="$t('page.system.menu.sections.route')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElCol v-if="showRouteKindField" :span="12">
|
||||
<ElFormItem prop="routeKind">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
@@ -1048,9 +1291,9 @@ watch(visible, value => {
|
||||
<ElFormItem prop="pageResourcePath">
|
||||
<template #label>
|
||||
<span class="business-form-label-with-tip">
|
||||
<span>{{ $t('page.system.menu.pageResource') }}</span>
|
||||
<span>{{ routeBindingFieldLabel }}</span>
|
||||
<ElTooltip
|
||||
:content="$t('page.system.menu.tips.pageResource')"
|
||||
:content="routeBindingFieldTip"
|
||||
popper-class="business-form-label-tooltip"
|
||||
placement="top-start"
|
||||
>
|
||||
@@ -1060,12 +1303,8 @@ watch(visible, value => {
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
<ElSelect
|
||||
v-model="model.pageResourcePath"
|
||||
filterable
|
||||
:placeholder="$t('page.system.menu.form.pageResource')"
|
||||
>
|
||||
<ElOption v-for="{ label, value } in pageResourceOptions" :key="value" :label="label" :value="value" />
|
||||
<ElSelect v-model="model.pageResourcePath" filterable :placeholder="routeBindingFieldPlaceholder">
|
||||
<ElOption v-for="{ label, value } in routeBindingOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuSearch' });
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
@@ -23,12 +32,26 @@ function search() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" :action-col-md="8" @reset="reset" @search="search">
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="props.disabled"
|
||||
:action-col-lg="8"
|
||||
:action-col-md="24"
|
||||
@reset="reset"
|
||||
@search="search"
|
||||
>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
||||
<ElSelect v-model="model.status" clearable class="w-full" :placeholder="$t('page.system.menu.form.menuStatus')">
|
||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { commonStatusRecord } from '@/constants/business';
|
||||
import { commonStatusRecord, objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { fetchDeleteRole, fetchGetMenuSimpleList, fetchGetRolePage } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import RoleContextPanel from './modules/role-context-panel.vue';
|
||||
import RoleOperateDialog from './modules/role-operate-dialog.vue';
|
||||
import RoleResourcePanel from './modules/role-resource-panel.vue';
|
||||
import RoleSearch from './modules/role-search.vue';
|
||||
@@ -61,15 +62,36 @@ function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const pendingSelectedRoleId = ref<number | null>(null);
|
||||
const selectedRoleId = ref<string | null>(null);
|
||||
const pendingSelectedRoleId = ref<string | null>(null);
|
||||
const scopeType = ref<Api.SystemManage.ScopeType>('global');
|
||||
const objectType = ref<Api.SystemManage.ObjectType>('product');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (scopeType.value === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: objectType.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetRolePage(searchParams),
|
||||
api: () =>
|
||||
fetchGetRolePage({
|
||||
...searchParams,
|
||||
...getCurrentScopeParams()
|
||||
}),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
@@ -134,8 +156,9 @@ const menuTree = ref<Api.SystemManage.MenuSimple[]>([]);
|
||||
|
||||
async function getMenuTreeData() {
|
||||
menuTreeLoading.value = true;
|
||||
menuTree.value = [];
|
||||
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList();
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList(getCurrentScopeParams());
|
||||
|
||||
menuTreeLoading.value = false;
|
||||
|
||||
@@ -148,6 +171,19 @@ async function getMenuTreeData() {
|
||||
}
|
||||
|
||||
const currentRole = computed(() => data.value.find(item => item.id === selectedRoleId.value) ?? null);
|
||||
const currentRoleTotal = computed(() => mobilePagination.value.total || data.value.length);
|
||||
const currentScopeLabel = computed(() => $t(scopeTypeRecord[scopeType.value]));
|
||||
const currentObjectTypeLabel = computed(() => {
|
||||
return objectType.value ? $t(objectTypeRecord[objectType.value]) : '';
|
||||
});
|
||||
const currentContextTagLabel = computed(() => {
|
||||
return isObjectScope.value && currentObjectTypeLabel.value
|
||||
? `${currentScopeLabel.value} / ${currentObjectTypeLabel.value}`
|
||||
: currentScopeLabel.value;
|
||||
});
|
||||
const currentTableTitle = computed(() => {
|
||||
return $t(isObjectScope.value ? 'page.system.role.objectRoleTitle' : 'page.system.role.globalRoleTitle');
|
||||
});
|
||||
|
||||
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
@@ -204,7 +240,7 @@ function handleSearch() {
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function selectRole(roleId: number) {
|
||||
function selectRole(roleId: string) {
|
||||
selectedRoleId.value = roleId;
|
||||
}
|
||||
|
||||
@@ -212,7 +248,7 @@ function handleRowClick(row: Api.SystemManage.Role) {
|
||||
selectRole(row.id);
|
||||
}
|
||||
|
||||
function handleSubmitted(roleId: number) {
|
||||
function handleSubmitted(roleId: string) {
|
||||
pendingSelectedRoleId.value = roleId;
|
||||
closeOperateModal();
|
||||
getData();
|
||||
@@ -241,82 +277,133 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(scopeType, value => {
|
||||
if (value === 'object' && !objectType.value) {
|
||||
objectType.value = 'product';
|
||||
}
|
||||
});
|
||||
|
||||
let contextChangeToken = 0;
|
||||
|
||||
watch([scopeType, objectType], async ([nextScope, nextObject], [prevScope, prevObject]) => {
|
||||
if (nextScope === prevScope && nextObject === prevObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextChangeToken += 1;
|
||||
const token = contextChangeToken;
|
||||
await nextTick();
|
||||
|
||||
if (token !== contextChangeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
selectedRoleId.value = null;
|
||||
pendingSelectedRoleId.value = null;
|
||||
editingData.value = null;
|
||||
closeOperateModal();
|
||||
await getMenuTreeData();
|
||||
await getDataByPage(1);
|
||||
});
|
||||
|
||||
getMenuTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.role.title') }}</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
@submitted="handleSubmitted"
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<RoleContextPanel
|
||||
v-model:scope-type="scopeType"
|
||||
v-model:object-type="objectType"
|
||||
:total="currentRoleTotal"
|
||||
:loading="loading || menuTreeLoading"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="role-page-content gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="role-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">{{ currentTableTitle }}</p>
|
||||
<ElTag effect="plain" :type="isObjectScope ? 'success' : 'primary'">
|
||||
{{ currentContextTagLabel }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ currentRoleTotal }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:scope-type="scopeType"
|
||||
:object-type="objectType"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-page-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.role-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
@@ -345,4 +432,18 @@ getMenuTreeData();
|
||||
:deep(.el-row) {
|
||||
margin: 0 0 -15px 0;
|
||||
}
|
||||
|
||||
.role-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/system/role/modules/role-context-panel.vue
Normal file
249
src/views/system/role/modules/role-context-panel.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { objectTypeRecord, scopeTypeRecord } from '@/constants/business';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleContextPanel' });
|
||||
|
||||
interface Props {
|
||||
total?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
const scopeType = defineModel<Api.SystemManage.ScopeType>('scopeType', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const objectType = defineModel<Api.SystemManage.ObjectType | undefined>('objectType');
|
||||
|
||||
const isObjectScope = computed(() => scopeType.value === 'object');
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: $t(scopeTypeRecord.global), value: 'global' satisfies Api.SystemManage.ScopeType },
|
||||
{ label: $t(scopeTypeRecord.object), value: 'object' satisfies Api.SystemManage.ScopeType }
|
||||
]);
|
||||
|
||||
const objectTypeOptions = computed(() => [
|
||||
{ label: $t(objectTypeRecord.product), value: 'product' satisfies Api.SystemManage.ObjectType },
|
||||
{ label: $t(objectTypeRecord.project), value: 'project' satisfies Api.SystemManage.ObjectType }
|
||||
]);
|
||||
|
||||
const currentContextLabel = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t(scopeTypeRecord.global);
|
||||
}
|
||||
|
||||
if (!objectType.value) {
|
||||
return `${$t(scopeTypeRecord.object)} / --`;
|
||||
}
|
||||
|
||||
return `${$t(scopeTypeRecord.object)} / ${$t(objectTypeRecord[objectType.value])}`;
|
||||
});
|
||||
|
||||
const currentScopeSummary = computed(() => {
|
||||
if (!isObjectScope.value) {
|
||||
return $t('page.system.role.globalRoleSummary');
|
||||
}
|
||||
|
||||
if (objectType.value === 'product') {
|
||||
return $t('page.system.role.objectRoleSummaryProduct');
|
||||
}
|
||||
|
||||
if (objectType.value === 'project') {
|
||||
return $t('page.system.role.objectRoleSummaryProject');
|
||||
}
|
||||
|
||||
return $t('page.system.role.objectRoleSummary');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="role-context-panel" body-class="role-context-panel__body">
|
||||
<div v-loading="props.loading" class="role-context-panel__layout">
|
||||
<div class="role-context-panel__controls">
|
||||
<div class="role-context-panel__field role-context-panel__field--switch">
|
||||
<ElSegmented v-model="scopeType" :options="scopeOptions" />
|
||||
</div>
|
||||
|
||||
<span v-if="isObjectScope" class="role-context-panel__divider" aria-hidden="true">|</span>
|
||||
|
||||
<div v-if="isObjectScope" class="role-context-panel__field role-context-panel__field--inline">
|
||||
<span class="role-context-panel__field-label role-context-panel__field-label--inline">
|
||||
{{ $t('page.system.menu.objectType') }}
|
||||
</span>
|
||||
<ElSelect v-model="objectType" class="w-full" :placeholder="$t('page.system.menu.objectTypePlaceholder')">
|
||||
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-context-panel__info">
|
||||
<div class="role-context-panel__info-main">
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.menu.currentContext') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ currentContextLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="role-context-panel__info-item">
|
||||
<span class="role-context-panel__info-label">{{ $t('page.system.role.currentRoleCount') }}</span>
|
||||
<strong class="role-context-panel__info-value">{{ props.total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="role-context-panel__info-desc">{{ currentScopeSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-context-panel {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.role-context-panel__layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.role-context-panel__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-context-panel__field--switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented) {
|
||||
width: auto;
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--switch .el-segmented__item) {
|
||||
min-height: 40px;
|
||||
min-width: 96px;
|
||||
padding: 0 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-context-panel__field--inline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__field-label--inline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-context-panel__divider {
|
||||
color: var(--el-border-color-darker);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:deep(.role-context-panel__field--inline .el-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.role-context-panel__info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-context-panel__info-desc {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.role-context-panel__layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.role-context-panel__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.role-context-panel__info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.role-context-panel__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-context-panel__info-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,12 +11,14 @@ defineOptions({ name: 'RoleOperateDialog' });
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Role | null;
|
||||
scopeType: Api.SystemManage.ScopeType;
|
||||
objectType?: Api.SystemManage.ObjectType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', roleId: number): void;
|
||||
(e: 'submitted', roleId: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -49,12 +51,27 @@ function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
code: '',
|
||||
scopeType: props.scopeType,
|
||||
objectType: props.scopeType === 'object' ? props.objectType : undefined,
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||
if (props.scopeType === 'object') {
|
||||
return {
|
||||
scopeType: 'object',
|
||||
objectType: props.objectType
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scopeType: 'global'
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.role.form.roleName')),
|
||||
code: createRequiredRule($t('page.system.role.form.roleCode')),
|
||||
@@ -85,6 +102,8 @@ async function initModel() {
|
||||
model.value = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
scopeType: data.scopeType ?? props.scopeType,
|
||||
objectType: data.objectType || (props.scopeType === 'object' ? props.objectType : undefined),
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark ?? ''
|
||||
@@ -102,26 +121,35 @@ async function handleSubmit() {
|
||||
|
||||
const submitData: Api.SystemManage.SaveRoleParams = {
|
||||
...model.value,
|
||||
...getCurrentScopeParams(),
|
||||
name: model.value.name.trim(),
|
||||
code: model.value.code.trim(),
|
||||
remark: model.value.remark?.trim() || null
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateRole({ id: props.rowData.id, ...submitData })
|
||||
: fetchCreateRole(submitData);
|
||||
let roleId = props.rowData?.id ?? '';
|
||||
|
||||
const { error, data } = await request;
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { scopeType: _scopeType, objectType: _objectType, ...updateData } = submitData;
|
||||
const { error } = await fetchUpdateRole({ id: props.rowData.id, ...updateData });
|
||||
|
||||
submitting.value = false;
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error, data } = await fetchCreateRole(submitData);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
roleId = data;
|
||||
}
|
||||
|
||||
const roleId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
@@ -142,7 +170,6 @@ watch(visible, value => {
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
|
||||
@@ -25,7 +25,7 @@ const treeRef = ref<TreeInstance | null>(null);
|
||||
const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
const checkedKeys = ref<string[]>([]);
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
@@ -37,7 +37,7 @@ const treeProps = {
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function applyCheckedKeys(keys: number[]) {
|
||||
function applyCheckedKeys(keys: string[]) {
|
||||
checkedKeys.value = [...keys];
|
||||
treeRef.value?.setCheckedKeys(keys);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ function filterNode(value: string, data: any) {
|
||||
}
|
||||
|
||||
function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
const ids: number[] = [];
|
||||
const ids: string[] = [];
|
||||
|
||||
const walk = (items: Api.SystemManage.MenuSimple[]) => {
|
||||
items.forEach(item => {
|
||||
@@ -112,7 +112,7 @@ async function loadRoleMenus() {
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -120,7 +120,7 @@ async function handleSave() {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as string[]) ?? [];
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
type TreeNodeId = string | number;
|
||||
|
||||
type TreeNode = {
|
||||
id: number;
|
||||
parentId: number;
|
||||
id: TreeNodeId;
|
||||
parentId: TreeNodeId;
|
||||
sort?: number | null;
|
||||
children?: TreeNode[] | null;
|
||||
};
|
||||
|
||||
export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
const nodeMap = new Map<number, T>();
|
||||
const nodeMap = new Map<TreeNodeId, T>();
|
||||
const roots: T[] = [];
|
||||
|
||||
list.forEach(item => {
|
||||
@@ -17,7 +19,7 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId === 0) {
|
||||
if (isRootParentId(node.parentId)) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
@@ -35,17 +37,17 @@ export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
return sortMenuTree(roots);
|
||||
}
|
||||
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number) {
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']) {
|
||||
const target = findTreeNode(nodes, targetId);
|
||||
|
||||
if (!target?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
const ids: T['id'][] = [];
|
||||
|
||||
walkTree(target.children, item => {
|
||||
ids.push(item.id);
|
||||
ids.push(item.id as T['id']);
|
||||
});
|
||||
|
||||
return ids;
|
||||
@@ -63,7 +65,7 @@ function sortMenuTree<T extends TreeNode>(nodes: T[]) {
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number): T | null {
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: T['id']): T | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node;
|
||||
@@ -81,6 +83,10 @@ function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], t
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRootParentId(parentId: TreeNodeId) {
|
||||
return parentId === 0 || parentId === '0';
|
||||
}
|
||||
|
||||
function walkTree<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], callback: (node: T) => void) {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
|
||||
@@ -54,10 +54,6 @@ const { fromUserIndex = false, deptId = 100, orgType = 'company' } = defineProps
|
||||
*
|
||||
* @param data 节点数据
|
||||
*/
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断母节点的编辑按钮是否应该隐藏
|
||||
*
|
||||
@@ -89,11 +85,15 @@ const userList = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const relationTreeRef = ref<InstanceType<typeof ElTree>>();
|
||||
|
||||
// 已选中的节点 ID 列表
|
||||
const checkedNodeKeys = ref<number[]>([]);
|
||||
const checkedNodeKeys = ref<string[]>([]);
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref<Api.SystemManage.UserManagementRelationTreeRespVO[]>([]);
|
||||
|
||||
function isRootNode(data: Api.SystemManage.UserManagementRelationTreeRespVO): boolean {
|
||||
return treeData.value.some(node => node.userId === data.userId);
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -225,7 +225,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'add';
|
||||
// 如果是从某一行的新增按钮触发,则默认管理者为当前节点用户
|
||||
// 否则默认管理者为当前登录用户(在对话框组件中处理)
|
||||
editingData.value = item ? {
|
||||
editingData.value = item
|
||||
? {
|
||||
id: null,
|
||||
managerUserId: item.userId,
|
||||
subordinateUserId: null,
|
||||
@@ -233,7 +234,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -245,7 +247,8 @@ function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
operateType.value = 'edit';
|
||||
// 构建树节点数据为编辑所需格式
|
||||
editingData.value = item.id ? {
|
||||
editingData.value = item.id
|
||||
? {
|
||||
id: item.id,
|
||||
managerUserId: item.managerUserId,
|
||||
subordinateUserId: item.userId,
|
||||
@@ -253,7 +256,8 @@ function openEdit(item: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
effectiveUntil: null,
|
||||
remark: null,
|
||||
createTime: Date.now()
|
||||
} : null;
|
||||
}
|
||||
: null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
@@ -300,7 +304,9 @@ async function handleBatchDelete() {
|
||||
* @param checkedInfo 包含 checkedKeys 和 halfCheckedKeys 的对象
|
||||
*/
|
||||
function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes.map((node: any) => node.id);
|
||||
checkedNodeKeys.value = checkedInfo.checkedNodes
|
||||
.map((node: any) => node.id)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +314,7 @@ function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
*
|
||||
* @param relationId 提交后的关系 ID
|
||||
*/
|
||||
function handleSubmitted(relationId: number) {
|
||||
function handleSubmitted(_relationId: string) {
|
||||
closeOperateModal();
|
||||
reloadTreeData();
|
||||
}
|
||||
@@ -411,14 +417,20 @@ onMounted(async () => {
|
||||
<span>{{ node.label }}</span>
|
||||
<!-- <ElTag v-if="data.managerNickname" size="small" type="info">上级:{{ data.managerNickname }}</ElTag>-->
|
||||
</span>
|
||||
<div class="flex items-center" style="min-width: 200px;">
|
||||
<div class="flex items-center" style="min-width: 200px">
|
||||
<ElButton link type="primary" size="default" @click.stop="openAdd(data)">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
<ElButton v-if="!(isRootNode(data) && shouldHideRootEdit)" link type="primary" size="small" @click.stop="openEdit(data)">
|
||||
<ElButton
|
||||
v-if="!(isRootNode(data) && shouldHideRootEdit)"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="openEdit(data)"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-edit class="text-icon" />
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const props = defineProps<Props>();
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
/** 提交事件:返回提交后的关系 ID */
|
||||
submitted: [relationId: number];
|
||||
submitted: [relationId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
@@ -126,11 +126,38 @@ function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function resetValidateState() {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function resolveDefaultManagerUserId() {
|
||||
if (props.rowData?.managerUserId) {
|
||||
return props.rowData.managerUserId;
|
||||
}
|
||||
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
if (!currentUserId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedById = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
if (matchedById) {
|
||||
return matchedById.id;
|
||||
}
|
||||
|
||||
return currentUserName ? props.userList.find(user => user.nickname === currentUserName)?.id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化表单模型
|
||||
*
|
||||
* 编辑模式下加载详情数据,新增模式下设置默认值
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
async function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
@@ -138,18 +165,18 @@ async function initModel() {
|
||||
// 新增模式:设置管理者用户
|
||||
// 优先使用 rowData 中传入的管理者用户 ID(如从树形节点新增)
|
||||
// 否则使用当前登录用户
|
||||
let managerUserIdToSet: number | undefined;
|
||||
let managerUserIdToSet = resolveDefaultManagerUserId();
|
||||
|
||||
if (props.rowData && props.rowData.managerUserId) {
|
||||
// 从树形节点点击新增,管理者为当前节点用户
|
||||
managerUserIdToSet = props.rowData.managerUserId;
|
||||
} else if (authStore.userInfo.userId) {
|
||||
// 头部新增,管理者为当前登录用户
|
||||
const currentUserId = Number(authStore.userInfo.userId);
|
||||
const currentUserId = authStore.userInfo.userId;
|
||||
const currentUserName = authStore.userInfo.userName;
|
||||
|
||||
// 先尝试通过 ID 匹配
|
||||
let currentUser = props.userList.find(user => Number(user.id) === currentUserId);
|
||||
let currentUser = props.userList.find(user => user.id === currentUserId);
|
||||
|
||||
// 如果 ID 匹配失败,尝试通过用户名匹配
|
||||
if (!currentUser && currentUserName) {
|
||||
@@ -165,26 +192,31 @@ async function initModel() {
|
||||
model.value.managerUserId = managerUserIdToSet;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 编辑模式:加载详情数据
|
||||
if (!props.rowData) {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = props.rowData.id;
|
||||
|
||||
if (!relationId) {
|
||||
await resetValidateState();
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
try {
|
||||
const { error, data } = await fetchGetUserManagementRelation(props.rowData.id);
|
||||
const { error, data } = await fetchGetUserManagementRelation(relationId);
|
||||
|
||||
if (data !== null && !error) {
|
||||
model.value = {
|
||||
id: data.id,
|
||||
id: data.id ?? undefined,
|
||||
managerUserId: data.managerUserId,
|
||||
subordinateUserId: data.subordinateUserId,
|
||||
effectiveFrom: data.effectiveFrom ? new Date(data.effectiveFrom).getTime() : null,
|
||||
@@ -196,8 +228,7 @@ async function initModel() {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
await resetValidateState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,23 +246,31 @@ async function handleSubmit() {
|
||||
...model.value
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? await fetchUpdateUserManagementRelation({ id: props.rowData.id, ...submitData })
|
||||
: await fetchCreateUserManagementRelation(submitData);
|
||||
const editRelationId = props.rowData?.id ?? null;
|
||||
|
||||
const { error, data } = request;
|
||||
if (isEdit.value && editRelationId) {
|
||||
const { error } = await fetchUpdateUserManagementRelation({ ...submitData, id: editRelationId });
|
||||
|
||||
if (error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('淇敼鎴愬姛');
|
||||
closeModal();
|
||||
emit('submitted', editRelationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const relationId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
const { error, data } = await fetchCreateUserManagementRelation(submitData);
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
|
||||
|
||||
closeModal();
|
||||
emit('submitted', relationId);
|
||||
emit('submitted', data);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="tsx">
|
||||
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue';
|
||||
import type {TableInstance} from 'element-plus';
|
||||
import {ElButton, ElPopconfirm, ElSwitch, ElTag} from 'element-plus';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElPopconfirm, ElSwitch, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type {FlatResponseData} from '@sa/axios';
|
||||
import {userGenderRecord} from '@/constants/business';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteUser,
|
||||
fetchDeleteDept,
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
fetchUpdateUser,
|
||||
fetchUpdateUserStatus
|
||||
} from '@/service/api';
|
||||
import {useUIPaginatedTable} from '@/hooks/common/table';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import {buildMenuTree} from '@/views/system/shared/menu-tree';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import UserManagementRelation from '@/views/system/user-management-relation/index.vue';
|
||||
import UserOperateDialog from './modules/user-operate-dialog.vue';
|
||||
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
|
||||
@@ -32,7 +32,7 @@ import UserResignedDialog from './modules/user-resigned-dialog.vue';
|
||||
import UserResetPasswordDialog from './modules/user-reset-password-dialog.vue';
|
||||
import UserSearch from './modules/user-search.vue';
|
||||
|
||||
defineOptions({name: 'UserManage'});
|
||||
defineOptions({ name: 'UserManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.UserSearchParams {
|
||||
return {
|
||||
@@ -158,7 +158,7 @@ const deptTree = computed(() => buildMenuTree(deptList.value));
|
||||
const currentDept = computed(() => deptList.value.find(item => item.id === currentDeptId.value) ?? null);
|
||||
const deptCount = computed(() => deptList.value.length);
|
||||
|
||||
const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} = useUIPaginatedTable<
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
@@ -182,9 +182,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{prop: 'selection', type: 'selection', width: 48},
|
||||
{prop: 'index', type: 'index', label: $t('common.index'), width: 64},
|
||||
{prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true},
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'nickname',
|
||||
label: $t('page.system.user.nickName'),
|
||||
@@ -274,9 +274,9 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
formatter: row => {
|
||||
const state = getUserResignedState(row);
|
||||
const stateMap: Record<UserResignedState, { type: UI.ThemeColor; label: App.I18n.I18nKey }> = {
|
||||
active: {type: 'success', label: 'page.system.user.resignedStateEnum.active'},
|
||||
pending: {type: 'warning', label: 'page.system.user.resignedStateEnum.pending'},
|
||||
resigned: {type: 'info', label: 'page.system.user.resignedStateEnum.resigned'}
|
||||
active: { type: 'success', label: 'page.system.user.resignedStateEnum.active' },
|
||||
pending: { type: 'warning', label: 'page.system.user.resignedStateEnum.pending' },
|
||||
resigned: { type: 'info', label: 'page.system.user.resignedStateEnum.resigned' }
|
||||
};
|
||||
|
||||
return <ElTag type={stateMap[state].type}>{$t(stateMap[state].label)}</ElTag>;
|
||||
@@ -337,7 +337,7 @@ const {columns, columnChecks, data, loading, getDataByPage, mobilePagination} =
|
||||
async function loadDeptTree() {
|
||||
deptLoading.value = true;
|
||||
|
||||
const {error, data: deptItems} = await fetchGetDeptList({
|
||||
const { error, data: deptItems } = await fetchGetDeptList({
|
||||
status: 0
|
||||
});
|
||||
|
||||
@@ -452,7 +452,7 @@ function openOrgLeader(row: Api.SystemManage.Dept) {
|
||||
}
|
||||
|
||||
async function handleDeleteDeptAction(row: Api.SystemManage.Dept) {
|
||||
const {error} = await fetchDeleteDept(row.id);
|
||||
const { error } = await fetchDeleteDept(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -477,7 +477,7 @@ async function handleDeleteAction(row: Api.SystemManage.User) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchDeleteUser(row.id);
|
||||
const { error } = await fetchDeleteUser(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -496,7 +496,7 @@ async function updateUserResignedAt(userId: number, value: number | null) {
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const {error} = await fetchUpdateUser({
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
@@ -548,7 +548,7 @@ async function handleBatchDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error} = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
const { error } = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
@@ -561,7 +561,7 @@ async function handleBatchDelete() {
|
||||
async function handleToggleStatus(row: Api.SystemManage.User, enabled: boolean) {
|
||||
statusLoadingIds.value = [...statusLoadingIds.value, row.id];
|
||||
|
||||
const {error} = await fetchUpdateUserStatus({
|
||||
const { error } = await fetchUpdateUserStatus({
|
||||
id: row.id,
|
||||
status: enabled ? 0 : 1
|
||||
});
|
||||
@@ -671,13 +671,13 @@ onMounted(async () => {
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="userManagementRelationVisible = true">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon"/>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
管理链路
|
||||
</ElButton>
|
||||
@@ -685,7 +685,7 @@ onMounted(async () => {
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="userCheckedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon"/>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
@@ -707,7 +707,7 @@ onMounted(async () => {
|
||||
:data="data"
|
||||
@selection-change="handleUserSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col"/>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
@@ -722,7 +722,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')"/>
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
@@ -763,7 +763,7 @@ onMounted(async () => {
|
||||
@submitted="handleDeptSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData"/>
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData" />
|
||||
|
||||
<BusinessFormDialog
|
||||
v-model="userManagementRelationVisible"
|
||||
@@ -772,7 +772,7 @@ onMounted(async () => {
|
||||
:show-footer="false"
|
||||
max-body-height="70vh"
|
||||
>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType"/>
|
||||
<UserManagementRelation :from-user-index="true" :dept-id="currentDeptId" :org-type="currentDept?.orgType" />
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,7 @@ const title = computed(() => {
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
type Model = Api.SystemManage.SaveUserParams & {
|
||||
roleIds: number[];
|
||||
roleIds: string[];
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {commonStatusOptions} from '@/constants/business';
|
||||
import {fetchCreateDept, fetchUpdateDept} from '@/service/api';
|
||||
import {useForm, useFormRules} from '@/hooks/common/form';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDept, fetchUpdateDept } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {$t} from '@/locales';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({name: 'UserOrgOperateDialog'});
|
||||
defineOptions({ name: 'UserOrgOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
@@ -28,8 +28,8 @@ const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const {formRef, validate} = useForm();
|
||||
const {createRequiredRule} = useFormRules();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -44,10 +44,10 @@ const title = computed(() => {
|
||||
});
|
||||
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{value: 'company', label: 'page.system.user.orgType.company'},
|
||||
{value: 'dept', label: 'page.system.user.orgType.dept'},
|
||||
{value: 'direction', label: 'page.system.user.orgType.direction'},
|
||||
{value: 'team', label: 'page.system.user.orgType.team'}
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
type Model = Api.SystemManage.SaveDeptParams;
|
||||
@@ -149,7 +149,7 @@ async function handleSubmit() {
|
||||
} as Api.SystemManage.SaveDeptParams;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const {error} = await fetchUpdateDept({
|
||||
const { error } = await fetchUpdateDept({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
@@ -166,7 +166,7 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const {error, data} = await fetchCreateDept(payload);
|
||||
const { error, data } = await fetchCreateDept(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
@@ -203,7 +203,7 @@ watch(visible, async value => {
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.orgName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')"/>
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
@@ -222,7 +222,7 @@ watch(visible, async value => {
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgTypeLabel')" prop="orgType">
|
||||
<ElSelect v-model="model.orgType" :placeholder="$t('page.system.user.form.orgTypeLabel')">
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value"/>
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -236,7 +236,7 @@ watch(visible, async value => {
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')"/>
|
||||
<ElInput v-else v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { SYSTEM_USER_COMPANY_DICT_CODE } from '@/constants/dict';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
@@ -8,7 +10,6 @@ defineOptions({ name: 'UserSearch' });
|
||||
|
||||
interface Props {
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
companyOptions: Api.Dict.DictData[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,15 +76,13 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem label="所属公司" prop="company">
|
||||
<ElSelect
|
||||
<DictSelect
|
||||
v-model="model.company"
|
||||
clearable
|
||||
:dict-code="SYSTEM_USER_COMPANY_DICT_CODE"
|
||||
filterable
|
||||
:disabled="disabled"
|
||||
placeholder="请选择所属公司"
|
||||
>
|
||||
<ElOption v-for="item in companyOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
|
||||
Reference in New Issue
Block a user