Release/test 合并上生产 #1

Merged
admin merged 236 commits from release/test into main 2026-09-17 11:51:50 +08:00
59 changed files with 691 additions and 325 deletions
Showing only changes of commit fed46f42ef - Show all commits
+8 -4
View File
@@ -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 后移除对应模拟分支,生产环境不得回退为模拟成功。
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
+10
View File
@@ -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 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
+1 -1
View File
@@ -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']
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()
+16
View File
@@ -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'
+2
View File
@@ -0,0 +1,2 @@
export { changeCurrentPassword } from './api/auth'
export { useAuthStore } from './store/auth'
+6
View File
@@ -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[]
+11 -4
View File
@@ -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 -4
View File
@@ -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="用户名"/)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+33
View File
@@ -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>
+16
View File
@@ -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()
+17
View File
@@ -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[]
+2
View File
@@ -0,0 +1,2 @@
export { useFirstLegOrdersStore } from './store/firstLegOrders'
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
+40
View File
@@ -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>
+33
View File
@@ -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>
+41
View File
@@ -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>
+24
View File
@@ -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)
+14
View File
@@ -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
+1 -1
View File
@@ -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'],
}
+13
View File
@@ -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'
+41
View File
@@ -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/)
})
+51
View File
@@ -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))
}
+26 -19
View File
@@ -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)
}
})
+37 -110
View File
@@ -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[],
+34
View File
@@ -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
View File
@@ -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: '/' },
],
+44
View File
@@ -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'])
})