diff --git a/.env.dev b/.env.dev
index 16fb7bd..efc7859 100644
--- a/.env.dev
+++ b/.env.dev
@@ -6,5 +6,5 @@ VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
}`
-# 鏄惁鍦ㄥ紑鍙戠幆澧冨惎鐢?Vue DevTools 娴姩鍏ュ彛
+# 是否在开发环境启用 Vue DevTools 浮动入口
VITE_DEVTOOLS_ENABLED=N
diff --git a/.gitattributes b/.gitattributes
index 9553ccb..3204a8d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,4 @@
"*.md" eol=lf
"*.yaml" eol=lf
"*.yml" eol=lf
+".*" text eol=lf
diff --git a/.gitignore b/.gitignore
index d13bb21..c844a81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,6 @@ yarn.lock
# Docs
/docs/*
!/docs/frontend-page-resource-manifest.json
+
+# Temp
+/codeTemp/*
diff --git a/AGENTS.md b/AGENTS.md
index 0dd7087..138abb9 100644
--- a/AGENTS.md
+++ b/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
+
+
+
+```
+
+```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`、`Set`,不要混用 `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 模式,不要平行引入另一套设计体系。
- 注释保持克制,只在代码本身不够直观时补充必要说明。
diff --git a/build/plugins/router.ts b/build/plugins/router.ts
index 8a885d1..22ce16d 100644
--- a/build/plugins/router.ts
+++ b/build/plugins/router.ts
@@ -29,6 +29,27 @@ export function setupElegantRouter() {
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
const routeMetaMap: Partial>> = {
+ 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,
diff --git a/docs/frontend-page-resource-manifest.json b/docs/frontend-page-resource-manifest.json
index 6d28a90..1b2b4fd 100644
--- a/docs/frontend-page-resource-manifest.json
+++ b/docs/frontend-page-resource-manifest.json
@@ -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.",
"singlePageComponentPattern": "layout.$view."
},
- "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,
diff --git a/route.json b/route.json
deleted file mode 100644
index 3175188..0000000
--- a/route.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/src/components/custom/dict-select.vue b/src/components/custom/dict-select.vue
new file mode 100644
index 0000000..ded9e89
--- /dev/null
+++ b/src/components/custom/dict-select.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/custom/dict-tag.vue b/src/components/custom/dict-tag.vue
new file mode 100644
index 0000000..94b4ba4
--- /dev/null
+++ b/src/components/custom/dict-tag.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/custom/dict-text.vue b/src/components/custom/dict-text.vue
new file mode 100644
index 0000000..adc3261
--- /dev/null
+++ b/src/components/custom/dict-text.vue
@@ -0,0 +1,53 @@
+
+
+
+
+ {{ text }}
+
+
+
+
diff --git a/src/constants/business.ts b/src/constants/business.ts
index 83e8a93..1618ca8 100644
--- a/src/constants/business.ts
+++ b/src/constants/business.ts
@@ -17,6 +17,20 @@ export const commonStatusOptions = [
{ value: 1, label: commonStatusRecord[1] }
] satisfies CommonType.Option[];
+export const scopeTypeRecord: Record = {
+ global: 'page.system.common.scopeType.global',
+ object: 'page.system.common.scopeType.object'
+};
+
+export const scopeTypeOptions = transformRecordToOption(scopeTypeRecord);
+
+export const objectTypeRecord: Record = {
+ 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'
diff --git a/src/constants/dict.ts b/src/constants/dict.ts
new file mode 100644
index 0000000..ab60a20
--- /dev/null
+++ b/src/constants/dict.ts
@@ -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';
diff --git a/src/constants/object-context.ts b/src/constants/object-context.ts
new file mode 100644
index 0000000..940fa3d
--- /dev/null
+++ b/src/constants/object-context.ts
@@ -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);
+}
diff --git a/src/constants/product-demo.ts b/src/constants/product-demo.ts
new file mode 100644
index 0000000..9c09297
--- /dev/null
+++ b/src/constants/product-demo.ts
@@ -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 = {
+ 规划中: 'info',
+ 研发中: 'warning',
+ 稳定运营: 'success'
+ };
+
+ return statusTypeMap[status];
+}
+
+export function getProductHealthType(health: DemoProduct['health']) {
+ const healthTypeMap: Record = {
+ 健康: 'success',
+ 关注: 'warning',
+ 加速: 'danger'
+ };
+
+ return healthTypeMap[health];
+}
diff --git a/src/directives/auth-shared.ts b/src/directives/auth-shared.ts
new file mode 100644
index 0000000..fb6f595
--- /dev/null
+++ b/src/directives/auth-shared.ts
@@ -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;
+}
diff --git a/src/directives/auth.ts b/src/directives/auth.ts
new file mode 100644
index 0000000..4df212d
--- /dev/null
+++ b/src/directives/auth.ts
@@ -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 = {
+ mounted(el, binding) {
+ bindAuthEffect(el, binding.value);
+ },
+ updated(el, binding) {
+ bindAuthEffect(el, binding.value);
+ },
+ unmounted(el) {
+ el.authStopHandle?.();
+ el.authStopHandle = null;
+ }
+};
diff --git a/src/directives/index.ts b/src/directives/index.ts
new file mode 100644
index 0000000..96b17b9
--- /dev/null
+++ b/src/directives/index.ts
@@ -0,0 +1,6 @@
+import type { App } from 'vue';
+import { authDirective } from './auth';
+
+export function setupDirectives(app: App) {
+ app.directive('auth', authDirective);
+}
diff --git a/src/enum/index.ts b/src/enum/index.ts
index 2739b3a..843f1c3 100644
--- a/src/enum/index.ts
+++ b/src/enum/index.ts
@@ -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'
}
diff --git a/src/hooks/business/auth.ts b/src/hooks/business/auth.ts
index f8fc749..01c48ca 100644
--- a/src/hooks/business/auth.ts
+++ b/src/hooks/business/auth.ts
@@ -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
};
}
diff --git a/src/hooks/business/dict.ts b/src/hooks/business/dict.ts
new file mode 100644
index 0000000..9bcda7a
--- /dev/null
+++ b/src/hooks/business/dict.ts
@@ -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 | ComputedRef;
+type DictValue = string | number | null | undefined;
+type DictValueList = Array | 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) {
+ 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
+ };
+}
diff --git a/src/hooks/common/router.ts b/src/hooks/common/router.ts
index 4ab21ea..4b71d76 100644
--- a/src/hooks/common/router.ts
+++ b/src/hooks/common/router.ts
@@ -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;
diff --git a/src/layouts/context/index.ts b/src/layouts/context/index.ts
index 56ff229..10e889e 100644
--- a/src/layouts/context/index.ts
+++ b/src/layouts/context/index.ts
@@ -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(() => 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(() => routeStore.menus);
+ const fallbackFirstLevelMenuKey =
+ allMenus.value.find(menu => isMenuMatchedByPath(route.path, menu.routePath))?.key || '';
+
+ setActiveFirstLevelMenuKey(fallbackFirstLevelMenuKey);
+ }
const firstLevelMenus = computed(() =>
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,
diff --git a/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue b/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue
index 8d80ec0..dae7519 100644
--- a/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue
+++ b/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue
@@ -1,8 +1,10 @@
@@ -60,9 +103,19 @@ function isMenuActive(menu: App.Global.Menu) {
{{ activeFirstLevelMenu.label }}
-
-