diff --git a/AGENTS.md b/AGENTS.md index a7f2244..4a24a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,15 @@ ## 目录与职责 -- `src/views/`:路由级页面和业务流程入口;复用交互拆入组件。 +- `src/modules/`:按业务域纵向组织页面、路由、业务组件、API、store、类型、数据适配和业务工具。新增业务页面必须进入对应模块,不再在 `src/views/` 堆放。 +- `src/modules//routes.ts`:作为模块 manifest 同时声明页面路由、导航元数据和发布状态;`src/modules/registry.ts` 统一组装模块,`src/router/index.ts` 只负责应用壳层和全局守卫。 +- manifest 中 `release: 'active'` 的页面才会进入路由和导航;占位页面必须标记为 `release: 'prototype'`,保留源码但不可通过 URL 访问。接口、权限资源和产品范围确认后才能改为 `active`。 +- `src/components/common/`:跨两个及以上业务模块实际复用的通用组件;仅被一个模块使用的组件保留在该模块内。 - `src/layouts/`:租户工作台壳层、导航和全局页面结构。 - `src/router/`:公开页、受保护路由、页面标题和权限元数据;导航与路由必须同步。 -- `src/stores/`:认证、当前租户和跨页面业务状态;局部表单状态留在页面或组件内。 -- `src/api/`:统一处理基础地址、超时、认证头、响应解包和错误映射;页面不得直接创建独立 Axios 客户端。 +- 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。 +- `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。 +- 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `public.ts` 导入。模块内稳定契约放在 `contracts.ts`,稳定的跨应用模型仍应上移到 `@chongchong/shared`。 - `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 - `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。 - 跨应用稳定模型放到 `packages/shared`,客户工作台专属类型保留在本项目。 @@ -29,7 +33,7 @@ - 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。 - 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。 -- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌逻辑或持久化密码、敏感资质内容。 +- 认证统一经过 `src/modules/auth/store/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌逻辑或持久化密码、敏感资质内容。 - 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。 - 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。 diff --git a/README.md b/README.md index 01b392e..ef71044 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ pnpm build pnpm test ``` +## 源码组织 + +业务代码按领域放在 `src/modules//`。每个模块可以包含 `views/`、`components/`、`api/`、`store/`、`types/`、`data/`、`utils/`、`contracts.ts`、`public.ts` 和 `routes.ts`,只创建实际需要的目录。 + +模块的 `routes.ts` 是路由与导航共用的 manifest。正式页面标记 `release: 'active'`;尚未接入接口、权限或未进入当前范围的占位页面标记 `release: 'prototype'`,不会被注册为可访问路由。`src/modules/registry.ts` 负责统一装配模块,根路由不再逐页维护配置。 + +跨模块调用只能经过目标模块的 `public.ts`;模块内部 API、store、组件和工具不作为跨模块接口。租户状态、权限映射等稳定模块契约放在 `contracts.ts`。 + +`src/components/common/` 只存放真实跨模块复用的组件,`src/api/` 只存放 HTTP、Token 会话和统一响应等基础设施。模块私有能力不要提前上移到全局目录。 + 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 diff --git a/src/components.d.ts b/src/components.d.ts index 1609281..905aff4 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -46,8 +46,8 @@ declare module 'vue' { ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] - FirstLegOrderEditorShell: typeof import('./components/first-leg/FirstLegOrderEditorShell.vue')['default'] LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default'] + PrototypeFeatureView: typeof import('./components/common/PrototypeFeatureView.vue')['default'] QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index 0b6c2be..9fb3e6e 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -6,7 +6,7 @@ import { filterNavigationByPermissions, getNavigationParentKeys, } from '@/navigation/clientNavigation' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/public' const props = defineProps<{ activePath: string diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index f31733c..2b7f5b5 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -5,7 +5,7 @@ import { ElMessageBox } from 'element-plus' import { LogOut, Menu, X } from '@lucide/vue' import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png' import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/public' const route = useRoute() const router = useRouter() diff --git a/src/modules/account/routes.ts b/src/modules/account/routes.ts new file mode 100644 index 0000000..b921084 --- /dev/null +++ b/src/modules/account/routes.ts @@ -0,0 +1,16 @@ +import { CircleUserRound } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const accountModule = { + key: 'account', + routes: [ + { + path: 'account/profile', + name: 'account-profile', + component: () => import('./views/AccountProfileView.vue'), + release: 'active', + meta: { title: '我的信息', description: '维护个人联系方式和登录密码', permission: 'account:profile:view' }, + navigation: { key: 'account-profile', icon: CircleUserRound }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/account/views/AccountProfileView.test.mjs b/src/modules/account/views/AccountProfileView.test.mjs index 980e849..d4327b8 100644 --- a/src/modules/account/views/AccountProfileView.test.mjs +++ b/src/modules/account/views/AccountProfileView.test.mjs @@ -2,16 +2,16 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import test from 'node:test' -const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8') -const navigationSource = fs.readFileSync(new URL('../navigation/clientNavigation.ts', import.meta.url), 'utf8') +const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8') -const authSource = fs.readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8') -const authApiSource = fs.readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8') +const authSource = fs.readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8') +const authApiSource = fs.readFileSync(new URL('../../auth/api/auth.ts', import.meta.url), 'utf8') test('registers the account profile route and navigation entry', () => { - assert.match(routerSource, /account\/profile'.*AccountProfileView\.vue/) + assert.match(routerSource, /path: 'account\/profile'[\s\S]*?component: \(\) => import\('\.\/views\/AccountProfileView\.vue'\)/) + assert.match(routerSource, /release: 'active'/) assert.match(routerSource, /permission: 'account:profile:view'/) - assert.match(navigationSource, /title: '我的信息'.*path: '\/account\/profile'/) + assert.match(routerSource, /navigation: \{ key: 'account-profile'/) }) test('implements profile view, edit, save and cancel states', () => { diff --git a/src/modules/account/views/AccountProfileView.vue b/src/modules/account/views/AccountProfileView.vue index 4d68960..e678bd7 100644 --- a/src/modules/account/views/AccountProfileView.vue +++ b/src/modules/account/views/AccountProfileView.vue @@ -12,14 +12,13 @@ import { UserRound, X, } from '@lucide/vue' -import { useAuthStore } from '@/stores/auth' -import { changeCurrentPassword } from '@/api/auth' +import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public' import { getPasswordStrength, validateEditableAccountProfile, validatePasswordChange, type PasswordChangePayload, -} from '@/utils/accountProfile' +} from '@/modules/account/utils/accountProfile' type AccountTab = 'profile' | 'password' diff --git a/src/modules/auth/public.ts b/src/modules/auth/public.ts new file mode 100644 index 0000000..880bb79 --- /dev/null +++ b/src/modules/auth/public.ts @@ -0,0 +1,2 @@ +export { changeCurrentPassword } from './api/auth' +export { useAuthStore } from './store/auth' diff --git a/src/modules/auth/routes.ts b/src/modules/auth/routes.ts new file mode 100644 index 0000000..028ed1a --- /dev/null +++ b/src/modules/auth/routes.ts @@ -0,0 +1,6 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const authRoutes = [ + { path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { title: '登录' } }, + { path: '/register', name: 'register', component: () => import('./views/RegisterView.vue'), meta: { title: '企业注册' } }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 3098da6..9d3b214 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -1,12 +1,19 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { CurrentUser } from '@chongchong/shared' -import { loginMerchant, logoutMerchant } from '@/api/auth' +import { loginMerchant, logoutMerchant } from '@/modules/auth/api/auth' import { ApiError } from '@/api/http' -import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam' +import { + FRONTEND_TO_MERCHANT_PERMISSION_CODES, + TENANT_PERMISSION_GROUPS, + getMerchantPermissionResources, + getMerchantPersonalProfile, + getTenantProfile, + updateMerchantPersonalProfile, + type MerchantPermissionResource, +} from '@/modules/tenant-management/public' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session' -import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration' -import { decodeMerchantToken, type MerchantJwtPayload } from '@/utils/jwt' +import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))) const backendPermissionMapping = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 0609298..7652996 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -4,11 +4,11 @@ import test from 'node:test' const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8') const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8') -const authStoreSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8') -const httpSource = readFileSync(new URL('../api/http.ts', import.meta.url), 'utf8') -const sessionSource = readFileSync(new URL('../api/session.ts', import.meta.url), 'utf8') +const authStoreSource = readFileSync(new URL('../store/auth.ts', import.meta.url), 'utf8') +const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8') +const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8') const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8') -const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8') +const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8') test('matches the backend username login contract', () => { assert.match(loginSource, /label="用户名"/) diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue index ff26757..a63a068 100644 --- a/src/modules/auth/views/LoginView.vue +++ b/src/modules/auth/views/LoginView.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue' import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/store/auth' interface LoginForm { account: string diff --git a/src/modules/auth/views/RegisterView.vue b/src/modules/auth/views/RegisterView.vue index e381c32..5a83134 100644 --- a/src/modules/auth/views/RegisterView.vue +++ b/src/modules/auth/views/RegisterView.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ArrowLeft, Building2, CheckCircle2, Mail, Phone } from '@lucide/vue' import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png' -import { registerMerchant } from '@/api/auth' +import { registerMerchant } from '@/modules/auth/api/auth' interface RegisterForm { username: string diff --git a/src/modules/cross-border/routes.ts b/src/modules/cross-border/routes.ts new file mode 100644 index 0000000..e0a1c4e --- /dev/null +++ b/src/modules/cross-border/routes.ts @@ -0,0 +1,33 @@ +import { Package, PlaneTakeoff, Ship, Truck } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const crossBorderModule = { + key: 'cross-border', + navigationGroup: { title: '出海下单', icon: PlaneTakeoff }, + routes: [ + { + path: 'cross-border/procurement', + name: 'cross-border-procurement', + component: () => import('./views/ProcurementView.vue'), + release: 'prototype', + meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' }, + navigation: { key: 'cross-border-procurement', icon: Package }, + }, + { + path: 'cross-border/domestic-trucking', + name: 'cross-border-domestic-trucking', + component: () => import('./views/DomesticTruckingView.vue'), + release: 'prototype', + meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' }, + navigation: { key: 'cross-border-domestic-trucking', icon: Truck }, + }, + { + path: 'cross-border/pallet-shipping', + name: 'cross-border-pallet-shipping', + component: () => import('./views/PalletShippingView.vue'), + release: 'prototype', + meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' }, + navigation: { key: 'cross-border-pallet-shipping', icon: Ship }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/cross-border/views/DomesticTruckingView.vue b/src/modules/cross-border/views/DomesticTruckingView.vue index 716689f..d3ecc7e 100644 --- a/src/modules/cross-border/views/DomesticTruckingView.vue +++ b/src/modules/cross-border/views/DomesticTruckingView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/cross-border/views/PalletShippingView.vue b/src/modules/cross-border/views/PalletShippingView.vue index 4b485d7..e5cec3f 100644 --- a/src/modules/cross-border/views/PalletShippingView.vue +++ b/src/modules/cross-border/views/PalletShippingView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 789ffc3..edb2695 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/dashboard/routes.ts b/src/modules/dashboard/routes.ts new file mode 100644 index 0000000..675367b --- /dev/null +++ b/src/modules/dashboard/routes.ts @@ -0,0 +1,16 @@ +import { Gauge } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const dashboardModule = { + key: 'dashboard', + routes: [ + { + path: '', + name: 'dashboard', + component: () => import('./views/DashboardView.vue'), + release: 'active', + meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' }, + navigation: { key: 'dashboard', icon: Gauge }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue index 88efa57..df624e4 100644 --- a/src/modules/dashboard/views/DashboardView.vue +++ b/src/modules/dashboard/views/DashboardView.vue @@ -1,10 +1,9 @@ diff --git a/src/modules/first-leg/data/firstLegPartySource.ts b/src/modules/first-leg/data/firstLegPartySource.ts index 0052739..9674503 100644 --- a/src/modules/first-leg/data/firstLegPartySource.ts +++ b/src/modules/first-leg/data/firstLegPartySource.ts @@ -1,5 +1,5 @@ -import { getMerchantPartnerCandidates, type MerchantPartner } from '@/api/partners' -import type { FirstLegParty, FirstLegPartyRole } from '@/data/firstLegParties' +import { getMerchantPartnerCandidates, type MerchantPartner } from '@/modules/first-leg/api/partners' +import type { FirstLegParty, FirstLegPartyRole } from '@/modules/first-leg/data/firstLegParties' export interface FirstLegPartySource { listAvailable(role: FirstLegPartyRole): readonly FirstLegParty[] diff --git a/src/modules/first-leg/public.ts b/src/modules/first-leg/public.ts new file mode 100644 index 0000000..a7a171b --- /dev/null +++ b/src/modules/first-leg/public.ts @@ -0,0 +1,2 @@ +export { useFirstLegOrdersStore } from './store/firstLegOrders' +export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder' diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts new file mode 100644 index 0000000..5456266 --- /dev/null +++ b/src/modules/first-leg/routes.ts @@ -0,0 +1,40 @@ +import { FileText, Package, Ship, Zap } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const firstLegModule = { + key: 'first-leg', + navigationGroup: { title: '头程物流', icon: Ship }, + routes: [ + { + path: 'orders/first-leg', + name: 'first-leg-orders', + component: () => import('./views/FirstLegOrdersView.vue'), + release: 'active', + meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' }, + navigation: { key: 'first-leg-orders', title: '头程下单', icon: FileText }, + }, + { + path: 'orders/first-leg/create', + name: 'first-leg-order-create', + component: () => import('./views/FirstLegOrdersView.vue'), + release: 'active', + meta: { title: '创建头程订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' }, + }, + { + path: 'first-leg/import', + name: 'first-leg-import', + component: () => import('./views/BatchImportView.vue'), + release: 'prototype', + meta: { title: '一键导入', description: '批量导入头程订单', permission: 'merchant:first-leg:import' }, + navigation: { key: 'first-leg-import', icon: Package }, + }, + { + path: 'first-leg/quick-order', + name: 'first-leg-quick-order', + component: () => import('./views/QuickOrderView.vue'), + release: 'prototype', + meta: { title: '快速下单', description: '快速创建头程订单', permission: 'merchant:first-leg:quick-create' }, + navigation: { key: 'first-leg-quick-order', icon: Zap }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 6e4e948..ee082eb 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -14,12 +14,12 @@ import { type MerchantFirstLegOrder, type MerchantFirstLegOrderDetail, type MerchantWarehouse, -} from '@/api/firstLeg' -import type { MerchantTenantStatus } from '@/api/merchantIam' +} from '@/modules/first-leg/api/firstLeg' +import type { MerchantTenantStatus } from '@/modules/tenant-management/public' import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared' -import type { FirstLegPartySnapshot } from '@/data/firstLegParties' -import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/types/firstLegOrder' -import { canClientEditFirstLegOrder } from '@/utils/firstLegOrder' +import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties' +import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder' +import { canClientEditFirstLegOrder } from '@/modules/first-leg/utils/firstLegOrder' const countryCodes: Readonly> = { 中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU', diff --git a/src/modules/first-leg/views/BatchImportView.vue b/src/modules/first-leg/views/BatchImportView.vue index ac49ba4..f24454a 100644 --- a/src/modules/first-leg/views/BatchImportView.vue +++ b/src/modules/first-leg/views/BatchImportView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index abc39b5..00a660e 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -3,15 +3,15 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8') -const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8') -const editorShellSource = readFileSync(new URL('../components/first-leg/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8') +const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') +const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8') test('routes the confirmed first-leg path to the implemented order view', () => { - assert.match(routerSource, /path: 'orders\/first-leg'.*FirstLegOrdersView\.vue/) + assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/) }) test('opens order creation as a dedicated protected page while keeping draft editing in a drawer', () => { - assert.match(routerSource, /path: 'orders\/first-leg\/create'.*name: 'first-leg-order-create'.*permission: 'order:first-leg:create'/) + assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/) assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/) assert.match(source, /const isCreatePage = computed\(\(\) => route\.name === 'first-leg-order-create'\)/) assert.match(source, /]*:page="isCreatePage"/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index af7f27c..ec87657 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -6,13 +6,13 @@ import { AlertTriangle, Box, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' -import FirstLegOrderEditorShell from '@/components/first-leg/FirstLegOrderEditorShell.vue' -import { useFirstLegOrdersStore } from '@/stores/firstLegOrders' -import { useTenantAdministrationStore } from '@/stores/tenantAdministration' -import { useAuthStore } from '@/stores/auth' -import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/data/firstLegParties' -import { firstLegPartySource } from '@/data/firstLegPartySource' -import type { FirstLegOrder, FirstLegOrderDraft } from '@/types/firstLegOrder' +import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue' +import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders' +import { useTenantAdministrationStore } from '@/modules/tenant-management/public' +import { useAuthStore } from '@/modules/auth/public' +import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/modules/first-leg/data/firstLegParties' +import { firstLegPartySource } from '@/modules/first-leg/data/firstLegPartySource' +import type { FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder' import { canClientEditFirstLegOrder, createEmptyCargoItem, @@ -21,7 +21,7 @@ import { getFirstLegTransportModeLabel, summarizeFirstLegCargo, validateFirstLegOrderDraft, -} from '@/utils/firstLegOrder' +} from '@/modules/first-leg/utils/firstLegOrder' const store = useFirstLegOrdersStore() const tenantStore = useTenantAdministrationStore() diff --git a/src/modules/first-leg/views/QuickOrderView.vue b/src/modules/first-leg/views/QuickOrderView.vue index f4fd281..1a37296 100644 --- a/src/modules/first-leg/views/QuickOrderView.vue +++ b/src/modules/first-leg/views/QuickOrderView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/order-management/routes.ts b/src/modules/order-management/routes.ts new file mode 100644 index 0000000..d524233 --- /dev/null +++ b/src/modules/order-management/routes.ts @@ -0,0 +1,33 @@ +import { ClipboardList, Package, ReceiptText, Truck } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const orderManagementModule = { + key: 'order-management', + navigationGroup: { title: '订单管理', icon: ClipboardList }, + routes: [ + { + path: 'orders/my', + name: 'orders-my', + component: () => import('./views/MyOrdersView.vue'), + release: 'prototype', + meta: { title: '我的订单', description: '统一查询各类业务订单', permission: 'merchant:order:list' }, + navigation: { key: 'orders-my', icon: ReceiptText }, + }, + { + path: 'orders/inbound', + name: 'orders-inbound', + component: () => import('./views/InboundOrdersView.vue'), + release: 'prototype', + meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' }, + navigation: { key: 'orders-inbound', icon: Package }, + }, + { + path: 'orders/delivery', + name: 'orders-delivery', + component: () => import('./views/DeliveryOrdersView.vue'), + release: 'prototype', + meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' }, + navigation: { key: 'orders-delivery', icon: Truck }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/order-management/views/DeliveryOrdersView.vue b/src/modules/order-management/views/DeliveryOrdersView.vue index 5b7bfc2..71be540 100644 --- a/src/modules/order-management/views/DeliveryOrdersView.vue +++ b/src/modules/order-management/views/DeliveryOrdersView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/order-management/views/InboundOrdersView.vue b/src/modules/order-management/views/InboundOrdersView.vue index 48cbbd2..7134fee 100644 --- a/src/modules/order-management/views/InboundOrdersView.vue +++ b/src/modules/order-management/views/InboundOrdersView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/order-management/views/MyOrdersView.vue b/src/modules/order-management/views/MyOrdersView.vue index 9a63189..9bb03b9 100644 --- a/src/modules/order-management/views/MyOrdersView.vue +++ b/src/modules/order-management/views/MyOrdersView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts new file mode 100644 index 0000000..0f7a38b --- /dev/null +++ b/src/modules/overseas-warehouse/routes.ts @@ -0,0 +1,41 @@ +import { Package, Tags, Truck, Warehouse } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const overseasWarehouseModule = { + key: 'overseas-warehouse', + navigationGroup: { title: '海外仓', icon: Warehouse }, + routes: [ + { + path: 'overseas-warehouse/labeling', + name: 'overseas-labeling', + component: () => import('./views/LabelingView.vue'), + release: 'prototype', + meta: { title: '贴标申请', description: '提交海外仓贴标申请', permission: 'merchant:overseas-warehouse:labeling' }, + navigation: { key: 'overseas-labeling', icon: Tags }, + }, + { + path: 'overseas-warehouse/return-inbound', + name: 'overseas-return-inbound', + component: () => import('./views/ReturnInboundView.vue'), + release: 'prototype', + meta: { title: '退货入库', description: '登记海外仓退货入库', permission: 'merchant:overseas-warehouse:return-inbound' }, + navigation: { key: 'overseas-return-inbound', icon: Package }, + }, + { + path: 'overseas-warehouse/return-relabel', + name: 'overseas-return-relabel', + component: () => import('./views/ReturnRelabelView.vue'), + release: 'prototype', + meta: { title: '退货换标', description: '提交退货换标申请', permission: 'merchant:overseas-warehouse:return-relabel' }, + navigation: { key: 'overseas-return-relabel', icon: Tags }, + }, + { + path: 'overseas-warehouse/return-outbound', + name: 'overseas-return-outbound', + component: () => import('./views/ReturnOutboundView.vue'), + release: 'prototype', + meta: { title: '退货出库', description: '创建海外仓退货出库单', permission: 'merchant:overseas-warehouse:return-outbound' }, + navigation: { key: 'overseas-return-outbound', icon: Truck }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/overseas-warehouse/views/LabelingView.vue b/src/modules/overseas-warehouse/views/LabelingView.vue index 8d07fcd..2c3f2db 100644 --- a/src/modules/overseas-warehouse/views/LabelingView.vue +++ b/src/modules/overseas-warehouse/views/LabelingView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/overseas-warehouse/views/ReturnInboundView.vue b/src/modules/overseas-warehouse/views/ReturnInboundView.vue index e5c4b63..cde845e 100644 --- a/src/modules/overseas-warehouse/views/ReturnInboundView.vue +++ b/src/modules/overseas-warehouse/views/ReturnInboundView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/overseas-warehouse/views/ReturnOutboundView.vue b/src/modules/overseas-warehouse/views/ReturnOutboundView.vue index a2024dd..7e0d4b2 100644 --- a/src/modules/overseas-warehouse/views/ReturnOutboundView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOutboundView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/overseas-warehouse/views/ReturnRelabelView.vue b/src/modules/overseas-warehouse/views/ReturnRelabelView.vue index 794211e..6edbb36 100644 --- a/src/modules/overseas-warehouse/views/ReturnRelabelView.vue +++ b/src/modules/overseas-warehouse/views/ReturnRelabelView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/registry.ts b/src/modules/registry.ts new file mode 100644 index 0000000..da9c712 --- /dev/null +++ b/src/modules/registry.ts @@ -0,0 +1,24 @@ +import { accountModule } from './account/routes.ts' +import { crossBorderModule } from './cross-border/routes.ts' +import { dashboardModule } from './dashboard/routes.ts' +import { financeModule } from './finance/routes.ts' +import { firstLegModule } from './first-leg/routes.ts' +import { orderManagementModule } from './order-management/routes.ts' +import { overseasWarehouseModule } from './overseas-warehouse/routes.ts' +import { systemModule } from './system/routes.ts' +import { tenantManagementModule } from './tenant-management/routes.ts' +import { getActiveWorkspaceRoutes, type ClientModuleManifest } from './types.ts' + +export const clientModuleRegistry = [ + dashboardModule, + firstLegModule, + crossBorderModule, + overseasWarehouseModule, + orderManagementModule, + financeModule, + tenantManagementModule, + accountModule, + systemModule, +] satisfies readonly ClientModuleManifest[] + +export const activeWorkspaceRoutes = getActiveWorkspaceRoutes(clientModuleRegistry) diff --git a/src/modules/system/routes.ts b/src/modules/system/routes.ts new file mode 100644 index 0000000..2b3606a --- /dev/null +++ b/src/modules/system/routes.ts @@ -0,0 +1,14 @@ +import type { ClientModuleManifest } from '../types.ts' + +export const systemModule = { + key: 'system', + routes: [ + { + path: 'forbidden', + name: 'forbidden', + component: () => import('./views/ForbiddenView.vue'), + release: 'active', + meta: { title: '无权访问', description: '当前账号没有访问该页面的权限' }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/system/views/ForbiddenView.vue b/src/modules/system/views/ForbiddenView.vue index 7f28a3b..fbe692f 100644 --- a/src/modules/system/views/ForbiddenView.vue +++ b/src/modules/system/views/ForbiddenView.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { useRouter } from 'vue-router' import { ArrowLeft, ShieldX } from '@lucide/vue' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/public' import { getFirstAccessiblePath } from '@/navigation/clientNavigation' const router = useRouter() diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs index a129319..697e02b 100644 --- a/src/modules/tenant-management/api/merchantIam.test.mjs +++ b/src/modules/tenant-management/api/merchantIam.test.mjs @@ -3,8 +3,9 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8') -const storeSource = readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8') -const authSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8') +const storeSource = readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8') +const contractsSource = readFileSync(new URL('../contracts.ts', import.meta.url), 'utf8') +const authSource = readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8') test('declares the documented merchant role and member endpoints', () => { assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/) @@ -50,9 +51,11 @@ test('starts empty and only loads server role and member records', () => { }) test('shares the merchant permission map and preserves resources not rendered by the role editor', () => { - assert.match(storeSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/) - assert.match(storeSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/) + assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/) + assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/) + assert.match(storeSource, /from '@\/modules\/tenant-management\/contracts'/) assert.match(authSource, /FRONTEND_TO_MERCHANT_PERMISSION_CODES/) + assert.match(authSource, /from '@\/modules\/tenant-management\/public'/) assert.match(authSource, /Object\.entries\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)/) assert.match(storeSource, /const preservedPermissionIds = role\.permissionIds\.filter/) assert.match(storeSource, /const nextPermissionIds = \[\.\.\.new Set\(\[\.\.\.preservedPermissionIds, \.\.\.selectedPermissionIds\]\)\]/) diff --git a/src/modules/tenant-management/api/merchantIam.ts b/src/modules/tenant-management/api/merchantIam.ts index 4f32100..0e8c76b 100644 --- a/src/modules/tenant-management/api/merchantIam.ts +++ b/src/modules/tenant-management/api/merchantIam.ts @@ -1,6 +1,5 @@ import { apiBlobRequest, apiRequest } from '@/api/http' - -export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED' +import type { MerchantTenantStatus } from '../contracts' export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' export interface MerchantTenantProfile { diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts new file mode 100644 index 0000000..a28a402 --- /dev/null +++ b/src/modules/tenant-management/contracts.ts @@ -0,0 +1,83 @@ +export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED' + +export interface TenantPermissionItem { + code: string + label: string + description: string +} + +export interface TenantPermissionGroup { + key: string + label: string + items: TenantPermissionItem[] +} + +export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ + { + key: 'account', + label: '个人账号', + items: [{ code: 'account:profile:view', label: '维护我的信息', description: '查看并维护当前成员自己的资料和登录密码' }], + }, + { + key: 'dashboard', + label: '业务总览', + items: [{ code: 'dashboard:view', label: '查看总览', description: '查看企业统计和最近订单' }], + }, + { + key: 'orders', + label: '头程订单', + items: [ + { code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' }, + { code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' }, + { code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' }, + { code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' }, + { code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' }, + ], + }, + { + key: 'company', + label: '企业资料', + items: [ + { code: 'company:profile:view', label: '查看资料', description: '查看企业、联系人和资质资料' }, + { code: 'company:profile:edit', label: '编辑资料', description: '维护企业联系信息和业务地址' }, + { code: 'company:qualification:upload', label: '更新资质', description: '上传或更新企业资质文件' }, + ], + }, + { + key: 'members', + label: '企业成员', + items: [ + { code: 'company:member:list', label: '查看成员', description: '查看本企业成员列表' }, + { code: 'company:member:write', label: '维护成员', description: '新增、编辑、启停企业成员' }, + { code: 'company:member:reset-password', label: '重置密码', description: '重置企业成员登录密码' }, + ], + }, + { + key: 'roles', + label: '角色与权限', + items: [ + { code: 'company:role:list', label: '查看角色', description: '查看本企业角色列表' }, + { code: 'company:role:write', label: '维护角色', description: '新增、编辑、启停企业角色' }, + { code: 'company:permission:list', label: '查看权限', description: '查看角色拥有的菜单和操作权限' }, + { code: 'company:permission:assign', label: '分配权限', description: '为本企业角色分配菜单及操作权限' }, + ], + }, +] + +export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { + 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], + 'order:first-leg:create': ['merchant:order:create'], + 'order:first-leg:edit': ['merchant:order:edit'], + 'order:first-leg:submit': ['merchant:order:submit'], + 'order:first-leg:cancel': ['merchant:order:cancel'], + 'company:profile:view': ['merchant:profile:view'], + 'company:profile:edit': ['merchant:profile:edit'], + 'company:qualification:upload': ['merchant:qualification:upload'], + 'company:member:list': ['merchant:member:list'], + 'company:member:write': ['merchant:member:create', 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status'], + 'company:member:reset-password': ['merchant:member:reset-password'], + 'company:role:list': ['merchant:role:list'], + 'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'], + 'company:permission:list': ['merchant:role:list'], + 'company:permission:assign': ['merchant:role:grant'], +} diff --git a/src/modules/tenant-management/public.ts b/src/modules/tenant-management/public.ts new file mode 100644 index 0000000..e33e0e3 --- /dev/null +++ b/src/modules/tenant-management/public.ts @@ -0,0 +1,13 @@ +export { + getMerchantPermissionResources, + getMerchantPersonalProfile, + getTenantProfile, + updateMerchantPersonalProfile, + type MerchantPermissionResource, +} from './api/merchantIam' +export { + FRONTEND_TO_MERCHANT_PERMISSION_CODES, + TENANT_PERMISSION_GROUPS, + type MerchantTenantStatus, +} from './contracts' +export { useTenantAdministrationStore } from './store/tenantAdministration' diff --git a/src/modules/tenant-management/routes.ts b/src/modules/tenant-management/routes.ts new file mode 100644 index 0000000..2455c2c --- /dev/null +++ b/src/modules/tenant-management/routes.ts @@ -0,0 +1,41 @@ +import { Building2, KeyRound, Settings, ShieldCheck, Users } from '@lucide/vue' +import type { ClientModuleManifest } from '../types.ts' + +export const tenantManagementModule = { + key: 'company', + navigationGroup: { title: '企业管理', icon: Settings, dividerBefore: true }, + routes: [ + { + path: 'company/profile', + name: 'company-profile', + component: () => import('./views/CompanyProfileView.vue'), + release: 'active', + meta: { title: '企业资料', description: '维护企业基本信息、联系人和资质文件', permission: 'company:profile:view' }, + navigation: { key: 'company-profile', icon: Building2 }, + }, + { + path: 'organization/members', + name: 'members', + component: () => import('./views/MembersView.vue'), + release: 'active', + meta: { title: '企业成员', description: '管理本企业成员账号、状态和角色', permission: 'company:member:list' }, + navigation: { key: 'company-members', icon: Users }, + }, + { + path: 'organization/roles', + name: 'roles', + component: () => import('./views/RolesView.vue'), + release: 'active', + meta: { title: '角色管理', description: '建立符合企业分工的成员角色', permission: 'company:role:list' }, + navigation: { key: 'company-roles', icon: ShieldCheck }, + }, + { + path: 'organization/permissions', + name: 'permissions', + component: () => import('./views/PermissionsView.vue'), + release: 'active', + meta: { title: '菜单权限', description: '为本企业角色分配菜单及操作权限', permission: 'company:permission:list' }, + navigation: { key: 'company-permissions', icon: KeyRound }, + }, + ], +} satisfies ClientModuleManifest diff --git a/src/modules/tenant-management/store/tenantAdministration.ts b/src/modules/tenant-management/store/tenantAdministration.ts index fed76ec..8f4fb38 100644 --- a/src/modules/tenant-management/store/tenantAdministration.ts +++ b/src/modules/tenant-management/store/tenantAdministration.ts @@ -19,8 +19,12 @@ import { type MerchantRole, type MerchantMember, type MerchantPermissionResource, +} from '@/modules/tenant-management/api/merchantIam' +import { + FRONTEND_TO_MERCHANT_PERMISSION_CODES, + TENANT_PERMISSION_GROUPS, type MerchantTenantStatus, -} from '@/api/merchantIam' +} from '@/modules/tenant-management/contracts' export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE' @@ -67,90 +71,7 @@ export interface TenantRole { permissionCodes: string[] } -export interface TenantPermissionItem { - code: string - label: string - description: string -} - -export interface TenantPermissionGroup { - key: string - label: string - items: TenantPermissionItem[] -} - -export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ - { - key: 'account', - label: '个人账号', - items: [{ code: 'account:profile:view', label: '维护我的信息', description: '查看并维护当前成员自己的资料和登录密码' }], - }, - { - key: 'dashboard', - label: '业务总览', - items: [{ code: 'dashboard:view', label: '查看总览', description: '查看企业统计和最近订单' }], - }, - { - key: 'orders', - label: '头程订单', - items: [ - { code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' }, - { code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' }, - { code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' }, - { code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' }, - { code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' }, - ], - }, - { - key: 'company', - label: '企业资料', - items: [ - { code: 'company:profile:view', label: '查看资料', description: '查看企业、联系人和资质资料' }, - { code: 'company:profile:edit', label: '编辑资料', description: '维护企业联系信息和业务地址' }, - { code: 'company:qualification:upload', label: '更新资质', description: '上传或更新企业资质文件' }, - ], - }, - { - key: 'members', - label: '企业成员', - items: [ - { code: 'company:member:list', label: '查看成员', description: '查看本企业成员列表' }, - { code: 'company:member:write', label: '维护成员', description: '新增、编辑、启停企业成员' }, - { code: 'company:member:reset-password', label: '重置密码', description: '重置企业成员登录密码' }, - ], - }, - { - key: 'roles', - label: '角色与权限', - items: [ - { code: 'company:role:list', label: '查看角色', description: '查看本企业角色列表' }, - { code: 'company:role:write', label: '维护角色', description: '新增、编辑、启停企业角色' }, - { code: 'company:permission:list', label: '查看权限', description: '查看角色拥有的菜单和操作权限' }, - { code: 'company:permission:assign', label: '分配权限', description: '为本企业角色分配菜单及操作权限' }, - ], - }, -] - const allPermissionCodes = TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)) - -export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly> = { - 'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'], - 'order:first-leg:create': ['merchant:order:create'], - 'order:first-leg:edit': ['merchant:order:edit'], - 'order:first-leg:submit': ['merchant:order:submit'], - 'order:first-leg:cancel': ['merchant:order:cancel'], - 'company:profile:view': ['merchant:profile:view'], - 'company:profile:edit': ['merchant:profile:edit'], - 'company:qualification:upload': ['merchant:qualification:upload'], - 'company:member:list': ['merchant:member:list'], - 'company:member:write': ['merchant:member:create', 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status'], - 'company:member:reset-password': ['merchant:member:reset-password'], - 'company:role:list': ['merchant:role:list'], - 'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'], - 'company:permission:list': ['merchant:role:list'], - 'company:permission:assign': ['merchant:role:grant'], -} - const mappedMerchantPermissionCodes = new Set(Object.values(FRONTEND_TO_MERCHANT_PERMISSION_CODES).flat()) const emptyCompany: TenantCompanyProfile = { diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue index ef8928b..44e7d73 100644 --- a/src/modules/tenant-management/views/CompanyProfileView.vue +++ b/src/modules/tenant-management/views/CompanyProfileView.vue @@ -4,10 +4,10 @@ import { ElMessage } from 'element-plus' import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus' import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' -import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration' -import { useAuthStore } from '@/stores/auth' -import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile' -import { downloadQualificationImage, uploadBusinessLicense } from '@/api/merchantIam' +import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/modules/tenant-management/store/tenantAdministration' +import { useAuthStore } from '@/modules/auth/public' +import { formatQualificationFileSize, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile' +import { downloadQualificationImage, uploadBusinessLicense } from '@/modules/tenant-management/api/merchantIam' import { ApiError } from '@/api/http' const store = useTenantAdministrationStore() diff --git a/src/modules/tenant-management/views/MembersView.vue b/src/modules/tenant-management/views/MembersView.vue index 88174c9..3593fa2 100644 --- a/src/modules/tenant-management/views/MembersView.vue +++ b/src/modules/tenant-management/views/MembersView.vue @@ -4,8 +4,8 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem import { KeyRound, Pencil, Plus } from '@lucide/vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' -import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration' -import { useAuthStore } from '@/stores/auth' +import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/modules/tenant-management/store/tenantAdministration' +import { useAuthStore } from '@/modules/auth/public' const store = useTenantAdministrationStore() const auth = useAuthStore() diff --git a/src/modules/tenant-management/views/PermissionsView.vue b/src/modules/tenant-management/views/PermissionsView.vue index 5553c92..4403c6d 100644 --- a/src/modules/tenant-management/views/PermissionsView.vue +++ b/src/modules/tenant-management/views/PermissionsView.vue @@ -3,8 +3,9 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import { LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue' -import { TENANT_PERMISSION_GROUPS, useTenantAdministrationStore } from '@/stores/tenantAdministration' -import { useAuthStore } from '@/stores/auth' +import { useTenantAdministrationStore } from '@/modules/tenant-management/store/tenantAdministration' +import { TENANT_PERMISSION_GROUPS } from '@/modules/tenant-management/contracts' +import { useAuthStore } from '@/modules/auth/public' const store = useTenantAdministrationStore() const auth = useAuthStore() diff --git a/src/modules/tenant-management/views/RolesView.vue b/src/modules/tenant-management/views/RolesView.vue index 6b46f5f..f6ecce1 100644 --- a/src/modules/tenant-management/views/RolesView.vue +++ b/src/modules/tenant-management/views/RolesView.vue @@ -5,8 +5,8 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem import { LockKeyhole, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' -import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration' -import { useAuthStore } from '@/stores/auth' +import { useTenantAdministrationStore, type TenantRole } from '@/modules/tenant-management/store/tenantAdministration' +import { useAuthStore } from '@/modules/auth/public' const store = useTenantAdministrationStore() const auth = useAuthStore() diff --git a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs index 25f334d..4949a34 100644 --- a/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs +++ b/src/modules/tenant-management/views/TenantAdministrationViews.test.mjs @@ -2,18 +2,19 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import test from 'node:test' -const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8') +const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const profileSource = fs.readFileSync(new URL('./CompanyProfileView.vue', import.meta.url), 'utf8') const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.url), 'utf8') const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8') const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8') -const storeSource = fs.readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8') +const storeSource = fs.readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8') test('routes tenant administration modules to implemented views instead of the placeholder', () => { - assert.match(routerSource, /company\/profile'.*CompanyProfileView\.vue/) - assert.match(routerSource, /organization\/members'.*MembersView\.vue/) - assert.match(routerSource, /organization\/roles'.*RolesView\.vue/) - assert.match(routerSource, /organization\/permissions'.*PermissionsView\.vue/) + assert.match(routerSource, /path: 'company\/profile'[\s\S]*?CompanyProfileView\.vue/) + assert.match(routerSource, /path: 'organization\/members'[\s\S]*?MembersView\.vue/) + assert.match(routerSource, /path: 'organization\/roles'[\s\S]*?RolesView\.vue/) + assert.match(routerSource, /path: 'organization\/permissions'[\s\S]*?PermissionsView\.vue/) + assert.equal((routerSource.match(/release: 'active'/g) ?? []).length, 4) assert.doesNotMatch(routerSource, /ModuleView\.vue/) }) diff --git a/src/modules/types.ts b/src/modules/types.ts new file mode 100644 index 0000000..46d3e61 --- /dev/null +++ b/src/modules/types.ts @@ -0,0 +1,51 @@ +import type { Component } from 'vue' +import type { RouteRecordRaw } from 'vue-router' + +export type ClientRouteRelease = 'active' | 'prototype' + +export interface ClientRouteNavigation { + key: string + icon: Component + title?: string +} + +export interface ClientModuleNavigationGroup { + title: string + icon: Component + dividerBefore?: boolean +} + +interface ClientRouteBase { + path: string + name: string + component: Component | (() => Promise) + release: ClientRouteRelease + meta: { + title: string + description?: string + permission?: string + } +} + +export interface NavigableClientRoute extends ClientRouteBase { + navigation: ClientRouteNavigation + meta: ClientRouteBase['meta'] & { permission: string } +} + +export interface HiddenClientRoute extends ClientRouteBase { + navigation?: never +} + +export type ClientRouteDefinition = NavigableClientRoute | HiddenClientRoute + +export interface ClientModuleManifest { + key: string + navigationGroup?: ClientModuleNavigationGroup + routes: readonly ClientRouteDefinition[] +} + +export function getActiveWorkspaceRoutes(modules: readonly ClientModuleManifest[]): RouteRecordRaw[] { + return modules.flatMap((module) => module.routes + .filter((route) => route.release === 'active') + .map(({ release: _release, navigation: _navigation, ...route }) => route as RouteRecordRaw)) +} diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 0a0e52c..9216a8e 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -232,7 +232,7 @@ import { type ClientNavigationGroup, type ClientNavigationLeaf, type ClientNavigationNode, -} from '../clientNavigation.ts' +} from '../tree.ts' declare const icon: Component @@ -286,32 +286,39 @@ void parentLeaf }) test('keeps the dashboard route permission synchronized with navigation', () => { - const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8') - const dashboardRoute = routerSource.match(/\{ path: '', name: 'dashboard'.*?\}/)?.[0] + const routerSource = readFileSync(new URL('../modules/dashboard/routes.ts', import.meta.url), 'utf8') + const dashboardRoute = routerSource.match(/path: ''[\s\S]*?navigation: \{ key: 'dashboard'[\s\S]*?\}/)?.[0] assert.ok(dashboardRoute) assert.match(dashboardRoute, /permission: 'dashboard:view'/) }) -test('registers every prototype-only view through its corresponding business directory', () => { - const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8') +test('keeps every prototype view cataloged with permission metadata but unreleased', () => { + const routerSource = [ + 'first-leg', + 'cross-border', + 'overseas-warehouse', + 'order-management', + 'finance', + ].map((moduleName) => readFileSync(new URL(`../modules/${moduleName}/routes.ts`, import.meta.url), 'utf8')).join('\n') const routes = [ - ['first-leg/import', 'first-leg/BatchImportView.vue'], - ['first-leg/quick-order', 'first-leg/QuickOrderView.vue'], - ['cross-border/procurement', 'cross-border/ProcurementView.vue'], - ['cross-border/domestic-trucking', 'cross-border/DomesticTruckingView.vue'], - ['cross-border/pallet-shipping', 'cross-border/PalletShippingView.vue'], - ['overseas-warehouse/labeling', 'overseas-warehouse/LabelingView.vue'], - ['overseas-warehouse/return-inbound', 'overseas-warehouse/ReturnInboundView.vue'], - ['overseas-warehouse/return-relabel', 'overseas-warehouse/ReturnRelabelView.vue'], - ['overseas-warehouse/return-outbound', 'overseas-warehouse/ReturnOutboundView.vue'], - ['orders/my', 'order-management/MyOrdersView.vue'], - ['orders/inbound', 'order-management/InboundOrdersView.vue'], - ['orders/delivery', 'order-management/DeliveryOrdersView.vue'], - ['finance/payables', 'finance/PayableBillsView.vue'], + ['first-leg/import', 'BatchImportView.vue'], + ['first-leg/quick-order', 'QuickOrderView.vue'], + ['cross-border/procurement', 'ProcurementView.vue'], + ['cross-border/domestic-trucking', 'DomesticTruckingView.vue'], + ['cross-border/pallet-shipping', 'PalletShippingView.vue'], + ['overseas-warehouse/labeling', 'LabelingView.vue'], + ['overseas-warehouse/return-inbound', 'ReturnInboundView.vue'], + ['overseas-warehouse/return-relabel', 'ReturnRelabelView.vue'], + ['overseas-warehouse/return-outbound', 'ReturnOutboundView.vue'], + ['orders/my', 'MyOrdersView.vue'], + ['orders/inbound', 'InboundOrdersView.vue'], + ['orders/delivery', 'DeliveryOrdersView.vue'], + ['finance/payables', 'PayableBillsView.vue'], ] for (const [path, view] of routes) { - assert.match(routerSource, new RegExp(`path: '${path}'.*?@/views/${view.replace('.', '\\.')}`)) + assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`)) + assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), false) } }) diff --git a/src/navigation/clientNavigation.ts b/src/navigation/clientNavigation.ts index 19f49ad..f36319d 100644 --- a/src/navigation/clientNavigation.ts +++ b/src/navigation/clientNavigation.ts @@ -1,117 +1,44 @@ -import type { Component } from 'vue' +import { clientModuleRegistry } from '../modules/registry.ts' +import type { ClientModuleManifest } from '../modules/types.ts' import { - Building2, - CircleUserRound, - FileText, - Gauge, - KeyRound, - Settings, - Ship, - ShieldCheck, - Users, -} from '@lucide/vue' + getNavigationLeaves, + isNavigationGroup, + type ClientNavigationGroup, + type ClientNavigationLeaf, + type ClientNavigationNode, +} from './tree.ts' -export interface ClientNavigationBaseNode { - key: string - title: string - icon: Component - dividerBefore?: boolean - hidden?: boolean +export { + getNavigationLeaves, + type ClientNavigationGroup, + type ClientNavigationLeaf, + type ClientNavigationNode, +} from './tree.ts' + +function buildModuleNavigation(module: ClientModuleManifest): ClientNavigationNode[] { + const leaves: ClientNavigationLeaf[] = [] + for (const route of module.routes) { + if (route.release !== 'active' || !route.navigation) continue + leaves.push({ + key: route.navigation.key, + title: route.navigation.title ?? route.meta.title, + icon: route.navigation.icon, + path: route.path ? `/${route.path}` : '/', + permission: route.meta.permission, + }) + } + if (!module.navigationGroup) return leaves + if (leaves.length === 0) return [] + return [{ + key: module.key, + title: module.navigationGroup.title, + icon: module.navigationGroup.icon, + dividerBefore: module.navigationGroup.dividerBefore, + children: leaves as [ClientNavigationNode, ...ClientNavigationNode[]], + }] } -export interface ClientNavigationLeaf extends ClientNavigationBaseNode { - path: string - permission: string - children?: never -} - -export interface ClientNavigationGroup extends ClientNavigationBaseNode { - path?: never - permission?: never - children: readonly [ClientNavigationNode, ...ClientNavigationNode[]] -} - -export type ClientNavigationNode = ClientNavigationLeaf | ClientNavigationGroup - -export const clientNavigation: ClientNavigationNode[] = [ - { key: 'dashboard', title: '业务总览', icon: Gauge, path: '/', permission: 'dashboard:view' }, - { - key: 'first-leg', - title: '头程物流', - icon: Ship, - children: [ - { key: 'first-leg-orders', title: '头程下单', icon: FileText, path: '/orders/first-leg', permission: 'order:first-leg:list' }, - // 原型入口:接入批量导入接口与权限资源后再放开。 - // { key: 'first-leg-import', title: '一键导入', icon: Package, path: '/first-leg/import', permission: 'merchant:first-leg:import' }, - // 原型入口:接入快速下单接口与权限资源后再放开。 - // { key: 'first-leg-quick-order', title: '快速下单', icon: Zap, path: '/first-leg/quick-order', permission: 'merchant:first-leg:quick-create' }, - ], - }, - // 原型菜单:对应页面目录与路由已预留,接入后端接口、权限资源后取消注释。 - // { - // key: 'cross-border', - // title: '出海下单', - // icon: PlaneTakeoff, - // children: [ - // { key: 'cross-border-procurement', title: '海外采购', icon: Package, path: '/cross-border/procurement', permission: 'merchant:cross-border:procurement' }, - // { key: 'cross-border-domestic-trucking', title: '美国本土卡', icon: Truck, path: '/cross-border/domestic-trucking', permission: 'merchant:cross-border:domestic-trucking' }, - // { key: 'cross-border-pallet-shipping', title: '美本土散板', icon: Ship, path: '/cross-border/pallet-shipping', permission: 'merchant:cross-border:pallet-shipping' }, - // ], - // }, - // { - // key: 'overseas-warehouse', - // title: '海外仓', - // icon: Warehouse, - // children: [ - // { key: 'overseas-labeling', title: '贴标申请', icon: Tags, path: '/overseas-warehouse/labeling', permission: 'merchant:overseas-warehouse:labeling' }, - // { key: 'overseas-return-inbound', title: '退货入库', icon: Package, path: '/overseas-warehouse/return-inbound', permission: 'merchant:overseas-warehouse:return-inbound' }, - // { key: 'overseas-return-relabel', title: '退货换标', icon: Tags, path: '/overseas-warehouse/return-relabel', permission: 'merchant:overseas-warehouse:return-relabel' }, - // { key: 'overseas-return-outbound', title: '退货出库', icon: Truck, path: '/overseas-warehouse/return-outbound', permission: 'merchant:overseas-warehouse:return-outbound' }, - // ], - // }, - // { - // key: 'order-management', - // title: '订单管理', - // icon: ClipboardList, - // children: [ - // { key: 'orders-my', title: '我的订单', icon: ReceiptText, path: '/orders/my', permission: 'merchant:order:list' }, - // { key: 'orders-inbound', title: '入库订单列表', icon: Package, path: '/orders/inbound', permission: 'merchant:warehouse:inbound:list' }, - // { key: 'orders-delivery', title: '派送订单列表', icon: Truck, path: '/orders/delivery', permission: 'merchant:warehouse:delivery:list' }, - // ], - // }, - // { - // key: 'finance', - // title: '财务结算', - // icon: ReceiptText, - // children: [ - // { key: 'finance-payables', title: '应付账单', icon: ReceiptText, path: '/finance/payables', permission: 'merchant:finance:payables:list' }, - // ], - // }, - { - key: 'company', - title: '企业管理', - icon: Settings, - dividerBefore: true, - children: [ - { key: 'company-profile', title: '企业资料', icon: Building2, path: '/company/profile', permission: 'company:profile:view' }, - { key: 'company-members', title: '企业成员', icon: Users, path: '/organization/members', permission: 'company:member:list' }, - { key: 'company-roles', title: '角色管理', icon: ShieldCheck, path: '/organization/roles', permission: 'company:role:list' }, - { key: 'company-permissions', title: '菜单权限', icon: KeyRound, path: '/organization/permissions', permission: 'company:permission:list' }, - ], - }, - { key: 'account-profile', title: '我的信息', icon: CircleUserRound, path: '/account/profile', permission: 'account:profile:view' }, -] - -function isNavigationGroup(node: ClientNavigationNode): node is ClientNavigationGroup { - return 'children' in node && node.children !== undefined -} - -export function getNavigationLeaves(nodes: readonly ClientNavigationNode[]): ClientNavigationLeaf[] { - return nodes.flatMap((node) => { - if (isNavigationGroup(node)) return getNavigationLeaves(node.children) - return [node] - }) -} +export const clientNavigation: ClientNavigationNode[] = clientModuleRegistry.flatMap(buildModuleNavigation) export function filterNavigationByPermissions( nodes: readonly ClientNavigationNode[], diff --git a/src/navigation/tree.ts b/src/navigation/tree.ts new file mode 100644 index 0000000..5378136 --- /dev/null +++ b/src/navigation/tree.ts @@ -0,0 +1,34 @@ +import type { Component } from 'vue' + +export interface ClientNavigationBaseNode { + key: string + title: string + icon: Component + dividerBefore?: boolean + hidden?: boolean +} + +export interface ClientNavigationLeaf extends ClientNavigationBaseNode { + path: string + permission: string + children?: never +} + +export interface ClientNavigationGroup extends ClientNavigationBaseNode { + path?: never + permission?: never + children: readonly [ClientNavigationNode, ...ClientNavigationNode[]] +} + +export type ClientNavigationNode = ClientNavigationLeaf | ClientNavigationGroup + +export function isNavigationGroup(node: ClientNavigationNode): node is ClientNavigationGroup { + return 'children' in node && node.children !== undefined +} + +export function getNavigationLeaves(nodes: readonly ClientNavigationNode[]): ClientNavigationLeaf[] { + return nodes.flatMap((node) => { + if (isNavigationGroup(node)) return getNavigationLeaves(node.children) + return [node] + }) +} diff --git a/src/router/index.ts b/src/router/index.ts index f624b90..0280d8c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,41 +1,19 @@ import { createRouter, createWebHistory } from 'vue-router' import AppLayout from '@/layouts/AppLayout.vue' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/modules/auth/public' import { getFirstAccessiblePath } from '@/navigation/clientNavigation' +import { authRoutes } from '@/modules/auth/routes' +import { activeWorkspaceRoutes } from '@/modules/registry' const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { title: '登录' } }, - { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { title: '企业注册' } }, + ...authRoutes, { path: '/', component: AppLayout, meta: { requiresAuth: true }, - children: [ - { path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' } }, - { path: 'account/profile', name: 'account-profile', component: () => import('@/views/AccountProfileView.vue'), meta: { title: '我的信息', description: '维护个人联系方式和登录密码', permission: 'account:profile:view' } }, - { path: 'company/profile', name: 'company-profile', component: () => import('@/views/CompanyProfileView.vue'), meta: { title: '企业资料', description: '维护企业基本信息、联系人和资质文件', permission: 'company:profile:view' } }, - { path: 'organization/members', name: 'members', component: () => import('@/views/MembersView.vue'), meta: { title: '企业成员', description: '管理本企业成员账号、状态和角色', permission: 'company:member:list' } }, - { path: 'organization/roles', name: 'roles', component: () => import('@/views/RolesView.vue'), meta: { title: '角色管理', description: '建立符合企业分工的成员角色', permission: 'company:role:list' } }, - { path: 'organization/permissions', name: 'permissions', component: () => import('@/views/PermissionsView.vue'), meta: { title: '菜单权限', description: '为本企业角色分配菜单及操作权限', permission: 'company:permission:list' } }, - { path: 'orders/first-leg', name: 'first-leg-orders', component: () => import('@/views/FirstLegOrdersView.vue'), meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' } }, - { path: 'orders/first-leg/create', name: 'first-leg-order-create', component: () => import('@/views/FirstLegOrdersView.vue'), meta: { title: '创建头程订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' } }, - { path: 'first-leg/import', name: 'first-leg-import', component: () => import('@/views/first-leg/BatchImportView.vue'), meta: { title: '一键导入', description: '批量导入头程订单' } }, - { path: 'first-leg/quick-order', name: 'first-leg-quick-order', component: () => import('@/views/first-leg/QuickOrderView.vue'), meta: { title: '快速下单', description: '快速创建头程订单' } }, - { path: 'cross-border/procurement', name: 'cross-border-procurement', component: () => import('@/views/cross-border/ProcurementView.vue'), meta: { title: '海外采购', description: '管理海外采购订单' } }, - { path: 'cross-border/domestic-trucking', name: 'cross-border-domestic-trucking', component: () => import('@/views/cross-border/DomesticTruckingView.vue'), meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单' } }, - { path: 'cross-border/pallet-shipping', name: 'cross-border-pallet-shipping', component: () => import('@/views/cross-border/PalletShippingView.vue'), meta: { title: '美本土散板', description: '创建美国本土散板运输订单' } }, - { path: 'overseas-warehouse/labeling', name: 'overseas-labeling', component: () => import('@/views/overseas-warehouse/LabelingView.vue'), meta: { title: '贴标申请', description: '提交海外仓贴标申请' } }, - { path: 'overseas-warehouse/return-inbound', name: 'overseas-return-inbound', component: () => import('@/views/overseas-warehouse/ReturnInboundView.vue'), meta: { title: '退货入库', description: '登记海外仓退货入库' } }, - { path: 'overseas-warehouse/return-relabel', name: 'overseas-return-relabel', component: () => import('@/views/overseas-warehouse/ReturnRelabelView.vue'), meta: { title: '退货换标', description: '提交退货换标申请' } }, - { path: 'overseas-warehouse/return-outbound', name: 'overseas-return-outbound', component: () => import('@/views/overseas-warehouse/ReturnOutboundView.vue'), meta: { title: '退货出库', description: '创建海外仓退货出库单' } }, - { path: 'orders/my', name: 'orders-my', component: () => import('@/views/order-management/MyOrdersView.vue'), meta: { title: '我的订单', description: '统一查询各类业务订单' } }, - { path: 'orders/inbound', name: 'orders-inbound', component: () => import('@/views/order-management/InboundOrdersView.vue'), meta: { title: '入库订单列表', description: '查询海外仓入库订单' } }, - { path: 'orders/delivery', name: 'orders-delivery', component: () => import('@/views/order-management/DeliveryOrdersView.vue'), meta: { title: '派送订单列表', description: '查询海外仓派送订单' } }, - { path: 'finance/payables', name: 'finance-payables', component: () => import('@/views/finance/PayableBillsView.vue'), meta: { title: '应付账单', description: '查询企业应付账单' } }, - { path: 'forbidden', name: 'forbidden', component: () => import('@/views/ForbiddenView.vue'), meta: { title: '无权访问', description: '当前账号没有访问该页面的权限' } }, - ], + children: activeWorkspaceRoutes, }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], diff --git a/src/router/moduleArchitecture.test.mjs b/src/router/moduleArchitecture.test.mjs new file mode 100644 index 0000000..636d5f8 --- /dev/null +++ b/src/router/moduleArchitecture.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import test from 'node:test' +import { getActiveWorkspaceRoutes } from '../modules/types.ts' + +const routerSource = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') +const registrySource = readFileSync(new URL('../modules/registry.ts', import.meta.url), 'utf8') + +test('keeps route pages inside business modules instead of a root views directory', () => { + assert.equal(existsSync(new URL('../views', import.meta.url)), false) +}) + +test('composes every workspace module through the shared registry', () => { + const modules = [ + 'dashboardModule', + 'firstLegModule', + 'crossBorderModule', + 'overseasWarehouseModule', + 'orderManagementModule', + 'financeModule', + 'tenantManagementModule', + 'accountModule', + 'systemModule', + ] + + assert.match(routerSource, /import \{ activeWorkspaceRoutes \} from '@\/modules\/registry'/) + assert.match(routerSource, /children: activeWorkspaceRoutes/) + for (const moduleName of modules) { + assert.match(registrySource, new RegExp(`import \\{ ${moduleName} \\}`)) + assert.match(registrySource, new RegExp(`\\s${moduleName},`)) + } +}) + +test('excludes prototype routes from the active router records', () => { + const routes = getActiveWorkspaceRoutes([{ + key: 'fixture', + routes: [ + { path: 'active', name: 'active', component: () => Promise.resolve({}), release: 'active', meta: { title: 'Active' } }, + { path: 'prototype', name: 'prototype', component: () => Promise.resolve({}), release: 'prototype', meta: { title: 'Prototype' } }, + ], + }]) + + assert.deepEqual(routes.map((route) => route.path), ['active']) +})