优化构架
This commit is contained in:
@@ -16,11 +16,15 @@
|
||||
|
||||
## 目录与职责
|
||||
|
||||
- `src/views/`:路由级页面和业务流程入口;复用交互拆入组件。
|
||||
- `src/modules/`:按业务域纵向组织页面、路由、业务组件、API、store、类型、数据适配和业务工具。新增业务页面必须进入对应模块,不再在 `src/views/` 堆放。
|
||||
- `src/modules/<domain>/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 后移除对应模拟分支,生产环境不得回退为模拟成功。
|
||||
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
|
||||
|
||||
|
||||
@@ -23,6 +23,16 @@ pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 源码组织
|
||||
|
||||
业务代码按领域放在 `src/modules/<domain>/`。每个模块可以包含 `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 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
|
||||
|
||||
Vendored
+1
-1
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { changeCurrentPassword } from './api/auth'
|
||||
export { useAuthStore } from './store/auth'
|
||||
@@ -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[]
|
||||
@@ -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)
|
||||
|
||||
@@ -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="用户名"/)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="美国本土卡" description="创建美国本土卡车运输订单" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="美本土散板" description="创建美国本土散板运输订单" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="海外采购" description="管理海外采购订单" /></template>
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { AlertTriangle, ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue'
|
||||
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
|
||||
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getFirstLegStatusLabel, getFirstLegStatusTone } from '@/utils/firstLegOrder'
|
||||
import { getFirstLegStatusLabel, getFirstLegStatusTone, useFirstLegOrdersStore } from '@/modules/first-leg/public'
|
||||
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
|
||||
const orders = useFirstLegOrdersStore()
|
||||
const tenant = useTenantAdministrationStore()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReceiptText } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const financeModule = {
|
||||
key: 'finance',
|
||||
navigationGroup: { title: '财务结算', icon: ReceiptText },
|
||||
routes: [
|
||||
{
|
||||
path: 'finance/payables',
|
||||
name: 'finance-payables',
|
||||
component: () => import('./views/PayableBillsView.vue'),
|
||||
release: 'prototype',
|
||||
meta: { title: '应付账单', description: '查询企业应付账单', permission: 'merchant:finance:payables:list' },
|
||||
navigation: { key: 'finance-payables', icon: ReceiptText },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="应付账单" description="查询企业应付账单" /></template>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useFirstLegOrdersStore } from './store/firstLegOrders'
|
||||
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
|
||||
@@ -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
|
||||
@@ -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<Record<string, string>> = {
|
||||
中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="一键导入" description="批量导入头程订单" /></template>
|
||||
|
||||
@@ -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, /<FirstLegOrderEditorShell[^>]*:page="isCreatePage"/)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="快速下单" description="快速创建头程订单" /></template>
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="派送订单列表" description="查询海外仓派送订单" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="入库订单列表" description="查询海外仓入库订单" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="我的订单" description="统一查询各类业务订单" /></template>
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="贴标申请" description="提交海外仓贴标申请" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="退货入库" description="登记海外仓退货入库" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="退货出库" description="创建海外仓退货出库单" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="退货换标" description="提交退货换标申请" /></template>
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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\]\)\]/)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Record<string, readonly string[]>> = {
|
||||
'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'],
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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<Record<string, readonly string[]>> = {
|
||||
'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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<unknown>)
|
||||
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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
+5
-27
@@ -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: '/' },
|
||||
],
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
Reference in New Issue
Block a user