This commit is contained in:
2026-08-03 11:45:45 +08:00
parent 9fe5428c44
commit bc44a7942d
33 changed files with 555 additions and 86 deletions
+13 -18
View File
@@ -19,29 +19,23 @@ const stubs = {
}
describe('AdminSidebarMenu', () => {
it('renders grouped page entries and forwards the active route', () => {
it('renders the visible phase-one modules and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: adminNavigation, activePath: '/system/users', collapsed: false },
global: { stubs },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([
'业务管理',
'基础资料',
'系统管理',
])
expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([
'/customers',
'/customers/reviews',
'/warehouses',
'/orders/first-leg',
'/master-data/express-companies',
'/master-data/counterparties',
'/system/users',
'/system/roles',
'/system/permissions',
])
const groupTitles = wrapper.findAll('[data-kind="group"] header').map((node) => node.text())
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
expect(groupTitles).toContain('客户管理')
expect(groupTitles).toContain('系统管理')
expect(groupTitles).not.toContain('财务管理')
expect(paths).not.toContain('/dashboard')
expect(paths).toContain('/orders/first-leg')
expect(paths).not.toContain('/prototype/reports/overview')
expect(paths).toContain('/system/permissions')
})
it('forwards desktop collapse state to Element Plus', () => {
@@ -61,7 +55,8 @@ describe('AdminSidebarMenu', () => {
})
expect(wrapper.text()).toContain('菜单权限')
expect(wrapper.text()).not.toContain('客户管理')
expect(wrapper.text()).not.toContain('首页数据看板')
expect(wrapper.text()).not.toContain('客户列表')
})
it('recursively renders three navigation levels', () => {
+28 -26
View File
@@ -8,57 +8,59 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps phase-one pages grouped without exposing the deferred dashboard', () => {
it('keeps only implemented phase-one menus visible while retaining prototype nodes as comments', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'业务管理',
'基础资料',
'客户管理',
'国内仓',
'头程订单',
'通用',
'系统管理',
])
expect(adminNavigation[0].children?.map((item) => item.title)).toEqual([
'客户管理',
'客户列表',
'企业审核',
'仓库管理',
'头程订单',
])
expect(adminNavigation[1].children?.map((item) => item.title)).toEqual(['快递公司', '往来单位'])
expect(adminNavigation[2].children?.map((item) => item.title)).toEqual([
'内部用户',
'角色管理',
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司')
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'菜单权限',
])
})
it('assigns every page a unique path and permission code', () => {
it('assigns every page a unique path and keeps real permissions unique', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.map((item) => item.permission)
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
expect(paths).toEqual([
'/customers',
'/customers/reviews',
'/warehouses',
'/orders/first-leg',
'/master-data/express-companies',
'/master-data/counterparties',
'/system/users',
'/system/roles',
'/system/permissions',
])
expect(paths).not.toContain('/dashboard')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
expect(new Set(permissions).size).toBe(permissions.length)
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
it('recursively filters leaves without mutating the source tree', () => {
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/system/permissions'])
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toContain('/system/permissions')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/customers')
expect(filtered).not.toBe(adminNavigation)
expect(filtered[0]).not.toBe(adminNavigation[2])
expect(filtered.find((item) => item.key === 'system')).not.toBe(adminNavigation.find((item) => item.key === 'system'))
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
})
it('supports manually hiding a module without removing its route definition', () => {
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
expect(filtered).toEqual([])
})
it('keeps directories based on accessible descendants instead of a directory permission', () => {
const nodes: AdminNavigationNode[] = [{
key: 'operations',
+187 -16
View File
@@ -1,6 +1,5 @@
import type { Component } from 'vue'
import {
BriefcaseBusiness,
Building2,
ContactRound,
ClipboardList,
@@ -20,44 +19,215 @@ export interface AdminNavigationNode {
icon: Component
path?: string
permission?: string
/** Set to true to remove a prototype module from the sidebar without deleting its route. */
hidden?: boolean
children?: AdminNavigationNode[]
}
export interface AdminNavigationLeaf extends AdminNavigationNode {
path: string
permission: string
permission?: string
children?: never
}
export const adminNavigation: AdminNavigationNode[] = [
// 原型菜单暂未接入一期范围;恢复展示时移除对应注释即可。
// { key: 'dashboard', title: '首页数据看板', icon: ClipboardList, path: '/dashboard' },
{
key: 'business',
title: '业务管理',
icon: BriefcaseBusiness,
key: 'customers',
title: '客户管理',
icon: Building2,
children: [
{ key: 'customers', title: '客户管理', icon: Building2, path: '/customers', permission: 'platform:customer:list' },
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list' },
{ key: 'enterprise-reviews', title: '企业审核', icon: FileCheck2, path: '/customers/reviews', permission: 'platform:customer:review' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list' },
// { key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee' },
],
},
{
key: 'master-data',
title: '基础资料',
key: 'domestic-warehouse',
title: '国内仓',
icon: WarehouseIcon,
children: [
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
// { key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-list' },
// { key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup' },
// { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure' },
// { key: 'inbound-receive', title: '接收审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-receive' },
// { key: 'overseas-location', title: '库位管理', icon: WarehouseIcon, path: '/prototype/domestic-warehouse/location-management' },
// { key: 'pallet-management', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/domestic-warehouse/pallet-management' },
],
},
{
key: 'first-leg-orders',
title: '头程订单',
icon: ClipboardList,
path: '/orders/first-leg',
permission: 'platform:order:list',
},
/* {
key: 'overseas-delivery',
title: '海外派送',
icon: Truck,
children: [
{ key: 'outbound-dispatch', title: '派送单列表', icon: Truck, path: '/prototype/overseas-delivery/dispatch-list' },
{ key: 'outbound-vehicle', title: '排车列表', icon: Truck, path: '/prototype/overseas-delivery/vehicle-scheduling' },
{ key: 'outbound-bol', title: 'BOL', icon: FileCheck2, path: '/prototype/overseas-delivery/bol' },
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
],
},
{
key: 'overseas-inventory',
title: '海外仓库存',
icon: WarehouseIcon,
children: [
{ key: 'overseas-stock', title: '库存列表', icon: WarehouseIcon, path: '/prototype/overseas-inventory/stock-list' },
{ key: 'overseas-inbound', title: '海外入库单', icon: ClipboardList, path: '/prototype/overseas-inventory/inbound-orders' },
{ key: 'overseas-outbound', title: '出库单', icon: ClipboardList, path: '/prototype/overseas-inventory/outbound-orders' },
{ key: 'overseas-relabel', title: '换标订单', icon: ClipboardList, path: '/prototype/overseas-inventory/relabel-orders' },
],
},
{
key: 'service-center',
title: '服务中心',
icon: ClipboardList,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/prototype/service-center/booking-list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/prototype/service-center/truck-list' },
],
},
{
key: 'supplier-center',
title: '供应商中心',
icon: ContactRound,
children: [
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/prototype/supplier-center/supplier-list' },
],
},
{
key: 'container-management',
title: '货柜管理',
icon: WarehouseIcon,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/prototype/container-management/container-list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/prototype/container-management/manifest' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/prototype/container-management/preload-list' },
],
},
{
key: 'overseas-business',
title: '海外业务',
icon: Building2,
children: [
{ key: 'overseas-users', title: '用户管理', icon: Users, path: '/prototype/overseas-business/users' },
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/prototype/overseas-business/purchase-orders' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/prototype/overseas-business/local-truck' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/prototype/overseas-business/local-looseboard' },
],
},
{
key: 'overseas-warehouse-orders',
title: '海外仓下单',
icon: ClipboardList,
children: [
{ key: 'overseas-order-inbound', title: '退货入库', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-inbound' },
{ key: 'overseas-order-relabel', title: '退货换标', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-relabel' },
{ key: 'overseas-order-outbound', title: '退货出库', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-outbound' },
],
},
{
key: 'fba-returns',
title: 'FBA退货',
icon: FileCheck2,
children: [
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/prototype/fba-returns/inbound-orders' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/prototype/fba-returns/relabel-orders' },
{ key: 'fba-outbound-order', title: '出库单', icon: ClipboardList, path: '/prototype/fba-returns/outbound-orders' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/fba-returns/pallet-management' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/prototype/fba-returns/carton-management' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/prototype/fba-returns/product-catalog' },
{ key: 'fba-return-stock', title: '退货库存', icon: WarehouseIcon, path: '/prototype/fba-returns/return-stock' },
],
},
{
key: 'finance',
title: '财务管理',
icon: Database,
children: [
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
{ key: 'customer-arap', title: '客户应收应付', icon: Database, path: '/prototype/finance/customer-arap' },
{ key: 'customer-recon-stmt', title: '客户对账单', icon: FileCheck2, path: '/prototype/finance/customer-reconciliation' },
{ key: 'settlement-ar', title: '结算单应收', icon: FileCheck2, path: '/prototype/finance/settlement-ar' },
{ key: 'settlement-ap', title: '结算单应付', icon: FileCheck2, path: '/prototype/finance/settlement-ap' },
{ key: 'finance-cost', title: '费用分析', icon: Database, path: '/prototype/finance/cost-analysis' },
{ key: 'profit', title: '利润表', icon: Database, path: '/prototype/finance/profit-report' },
],
},
{
key: 'vehicle-management',
title: '车辆管理',
icon: Truck,
children: [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list' },
],
},
{
key: 'reports',
title: '报表中心',
icon: Database,
children: [
{ key: 'report-overview', title: '报表总览', icon: Database, path: '/prototype/reports/overview' },
{ key: 'report-income', title: '收支报表', icon: Database, path: '/prototype/reports/income-expense' },
{ key: 'report-ar-aging', title: '应收账龄分析', icon: Database, path: '/prototype/reports/ar-aging' },
{ key: 'report-cost', title: '费用构成分析', icon: Database, path: '/prototype/reports/cost-composition' },
{ key: 'report-freight', title: '运费报表', icon: Database, path: '/prototype/reports/freight' },
{ key: 'report-business', title: '业务统计报表', icon: Database, path: '/prototype/reports/business-statistics' },
],
},
*/
{
key: 'general',
title: '通用',
icon: Database,
children: [
// { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information' },
// { key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items' },
// { key: 'general-fba', title: 'FBA编码地址', icon: Database, path: '/prototype/general/fba-address' },
// { key: 'general-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/general/pallet-management' },
// { key: 'general-location', title: '库位/货架管理', icon: WarehouseIcon, path: '/prototype/general/location-management' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
// { key: 'general-voyage', title: '船期价格表', icon: Database, path: '/prototype/general/voyage-price-list' },
],
},
/* {
key: 'sales',
title: '销售管理',
icon: Users,
children: [
{ key: 'sales-visits', title: '拜访记录', icon: Users, path: '/prototype/sales/visit-records' },
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
],
},
{
key: 'messages',
title: '消息中心',
icon: ClipboardList,
children: [
{ key: 'message-management', title: '消息管理', icon: ClipboardList, path: '/prototype/messages/management' },
{ key: 'message-template', title: '模板管理', icon: ClipboardList, path: '/prototype/messages/templates' },
{ key: 'my-messages', title: '我的消息', icon: ClipboardList, path: '/prototype/messages/my-messages' },
],
},
*/
{
key: 'system',
title: '系统管理',
icon: Settings,
children: [
{ key: 'internal-users', title: '内部用户', icon: Users, path: '/system/users', permission: 'platform:user:list' },
{ key: 'roles', title: '角色管理', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
{ key: 'internal-users', title: '用户管理', icon: Users, path: '/system/users', permission: 'platform:user:list' },
{ key: 'roles', title: '角色权限', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
// { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management' },
// { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings' },
],
},
]
@@ -65,7 +235,7 @@ export const adminNavigation: AdminNavigationNode[] = [
export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] {
return nodes.flatMap((node) => {
if (node.children?.length) return getNavigationLeaves(node.children)
if (!node.path || !node.permission) return []
if (!node.path) return []
return [node as AdminNavigationLeaf]
})
}
@@ -75,11 +245,12 @@ export function filterAdminNavigation(
permissionCodes: ReadonlySet<string>,
): AdminNavigationNode[] {
return nodes.flatMap((node) => {
if (node.hidden) return []
if (node.children?.length) {
const children = filterAdminNavigation(node.children, permissionCodes)
return children.length ? [{ ...node, children }] : []
}
if (!node.path || !node.permission || !permissionCodes.has(node.permission)) return []
if (!node.path || (node.permission && !permissionCodes.has(node.permission))) return []
return [{ ...node }]
})
}
+2
View File
@@ -3,6 +3,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
import { decideRouteAccess } from '@/router/accessDecision'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
import { prototypeModuleRoutes } from '@/router/prototypeModuleRoutes'
const router = createRouter({
history: createWebHistory(),
@@ -24,6 +25,7 @@ const router = createRouter({
component: () => import('@/views/ModuleView.vue'),
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
},
...prototypeModuleRoutes,
{
path: 'customers',
name: 'customers',
+135
View File
@@ -0,0 +1,135 @@
import type { RouteRecordRaw } from 'vue-router'
import type { Component } from 'vue'
type PrototypeRoute = readonly [path: string, name: string, title: string]
type ModuleView = () => Promise<Component>
const moduleViews = {
dashboard: () => import('@/views/prototype/dashboard/ModuleView.vue'),
customerManagement: () => import('@/views/prototype/customer-management/ModuleView.vue'),
domesticWarehouse: () => import('@/views/prototype/domestic-warehouse/ModuleView.vue'),
overseasDelivery: () => import('@/views/prototype/overseas-delivery/ModuleView.vue'),
overseasInventory: () => import('@/views/prototype/overseas-inventory/ModuleView.vue'),
serviceCenter: () => import('@/views/prototype/service-center/ModuleView.vue'),
supplierCenter: () => import('@/views/prototype/supplier-center/ModuleView.vue'),
containerManagement: () => import('@/views/prototype/container-management/ModuleView.vue'),
overseasBusiness: () => import('@/views/prototype/overseas-business/ModuleView.vue'),
overseasWarehouseOrders: () => import('@/views/prototype/overseas-warehouse-orders/ModuleView.vue'),
fbaReturns: () => import('@/views/prototype/fba-returns/ModuleView.vue'),
finance: () => import('@/views/prototype/finance/ModuleView.vue'),
vehicleManagement: () => import('@/views/prototype/vehicle-management/ModuleView.vue'),
reports: () => import('@/views/prototype/reports/ModuleView.vue'),
general: () => import('@/views/prototype/general/ModuleView.vue'),
sales: () => import('@/views/prototype/sales/ModuleView.vue'),
messages: () => import('@/views/prototype/messages/ModuleView.vue'),
systemManagement: () => import('@/views/prototype/system-management/ModuleView.vue'),
} satisfies Record<string, ModuleView>
function createRoutes(view: ModuleView, routes: PrototypeRoute[]): RouteRecordRaw[] {
return routes.map(([path, name, title]) => ({
path,
name,
component: view,
props: { title, description: `用于承载${title}相关业务信息` },
meta: { title, description: `用于承载${title}相关业务信息` },
}))
}
export const prototypeModuleRoutes: RouteRecordRaw[] = [
...createRoutes(moduleViews.dashboard, [['dashboard', 'dashboard', '首页数据看板']]),
...createRoutes(moduleViews.customerManagement, [
['prototype/customer-management/warehouse-tier-fee', 'warehouse-tier-fee', '海外仓阶梯费用'],
]),
...createRoutes(moduleViews.domesticWarehouse, [
['prototype/domestic-warehouse/inbound-list', 'inbound-list', '入库单列表'],
['prototype/domestic-warehouse/inbound-dedup', 'inbound-dedup', '入库单测量'],
['prototype/domestic-warehouse/inbound-measure', 'inbound-measure', '测量数据审核'],
['prototype/domestic-warehouse/inbound-receive', 'inbound-receive', '接收审核'],
['prototype/domestic-warehouse/location-management', 'domestic-location-management', '库位管理'],
['prototype/domestic-warehouse/pallet-management', 'domestic-pallet-management', '托盘管理'],
]),
...createRoutes(moduleViews.overseasDelivery, [
['prototype/overseas-delivery/dispatch-list', 'outbound-dispatch', '派送单列表'],
['prototype/overseas-delivery/vehicle-scheduling', 'outbound-vehicle', '排车列表'],
['prototype/overseas-delivery/bol', 'outbound-bol', 'BOL'],
['prototype/overseas-delivery/self-pickup', 'outbound-selfpick', '自提单'],
]),
...createRoutes(moduleViews.overseasInventory, [
['prototype/overseas-inventory/stock-list', 'overseas-stock', '库存列表'],
['prototype/overseas-inventory/inbound-orders', 'overseas-inbound', '海外入库单'],
['prototype/overseas-inventory/outbound-orders', 'overseas-outbound', '出库单'],
['prototype/overseas-inventory/relabel-orders', 'overseas-relabel', '换标订单'],
]),
...createRoutes(moduleViews.serviceCenter, [
['prototype/service-center/booking-list', 'booking-list', '订舱列表'],
['prototype/service-center/truck-list', 'truck-list', '拖车列表'],
]),
...createRoutes(moduleViews.supplierCenter, [
['prototype/supplier-center/supplier-list', 'supplier-list', '供应商列表'],
]),
...createRoutes(moduleViews.containerManagement, [
['prototype/container-management/container-list', 'container-list', '货柜列表'],
['prototype/container-management/manifest', 'container-manifest', '装柜清单'],
['prototype/container-management/preload-list', 'container-preload', '预装清单'],
]),
...createRoutes(moduleViews.overseasBusiness, [
['prototype/overseas-business/users', 'overseas-users', '用户管理'],
['prototype/overseas-business/purchase-orders', 'overseas-purchase', '采购订单'],
['prototype/overseas-business/local-truck', 'overseas-truck', '本土卡车'],
['prototype/overseas-business/local-looseboard', 'overseas-looseboard', '本土散板'],
]),
...createRoutes(moduleViews.overseasWarehouseOrders, [
['prototype/overseas-warehouse-orders/return-inbound', 'overseas-order-inbound', '退货入库'],
['prototype/overseas-warehouse-orders/return-relabel', 'overseas-order-relabel', '退货换标'],
['prototype/overseas-warehouse-orders/return-outbound', 'overseas-order-outbound', '退货出库'],
]),
...createRoutes(moduleViews.fbaReturns, [
['prototype/fba-returns/inbound-orders', 'fba-inbound-order', '入库单'],
['prototype/fba-returns/relabel-orders', 'fba-relabel-order', '换标单'],
['prototype/fba-returns/outbound-orders', 'fba-outbound-order', '出库单'],
['prototype/fba-returns/pallet-management', 'fba-pallet', '托盘管理'],
['prototype/fba-returns/carton-management', 'fba-carton', '箱号管理'],
['prototype/fba-returns/product-catalog', 'fba-product', '产品库'],
['prototype/fba-returns/return-stock', 'fba-return-stock', '退货库存'],
]),
...createRoutes(moduleViews.finance, [
['prototype/finance/customer-arap', 'customer-arap', '客户应收应付'],
['prototype/finance/customer-reconciliation', 'customer-recon-stmt', '客户对账单'],
['prototype/finance/settlement-ar', 'settlement-ar', '结算单应收'],
['prototype/finance/settlement-ap', 'settlement-ap', '结算单应付'],
['prototype/finance/cost-analysis', 'finance-cost', '费用分析'],
['prototype/finance/profit-report', 'profit', '利润表'],
]),
...createRoutes(moduleViews.vehicleManagement, [
['prototype/vehicle-management/vehicle-list', 'vehicle-list', '车辆列表'],
]),
...createRoutes(moduleViews.reports, [
['prototype/reports/overview', 'report-overview', '报表总览'],
['prototype/reports/income-expense', 'report-income', '收支报表'],
['prototype/reports/ar-aging', 'report-ar-aging', '应收账龄分析'],
['prototype/reports/cost-composition', 'report-cost', '费用构成分析'],
['prototype/reports/freight', 'report-freight', '运费报表'],
['prototype/reports/business-statistics', 'report-business', '业务统计报表'],
]),
...createRoutes(moduleViews.general, [
['prototype/general/product-information', 'general-product', '产品信息'],
['prototype/general/fee-items', 'general-fee', '费用项目'],
['prototype/general/fba-address', 'general-fba', 'FBA编码地址'],
['prototype/general/pallet-management', 'general-pallet', '托盘管理'],
['prototype/general/location-management', 'general-location', '库位/货架管理'],
['prototype/general/voyage-price-list', 'general-voyage', '船期价格表'],
]),
...createRoutes(moduleViews.sales, [
['prototype/sales/visit-records', 'sales-visits', '拜访记录'],
['prototype/sales/commission', 'sales-commission', '销售提成'],
]),
...createRoutes(moduleViews.messages, [
['prototype/messages/management', 'message-management', '消息管理'],
['prototype/messages/templates', 'message-template', '模板管理'],
['prototype/messages/my-messages', 'my-messages', '我的消息'],
]),
...createRoutes(moduleViews.systemManagement, [
['prototype/system-management/tenant-management', 'tenant-management', '租户管理'],
['prototype/system-management/brand-settings', 'brand-settings', '品牌设置'],
]),
]
+17 -1
View File
@@ -7,7 +7,23 @@
.brand-logo-mark { display: none; width: 40px; height: 40px; }
.is-collapsed .brand-logo-full { display: none; }
.is-collapsed .brand-logo-mark { display: block; }
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
.side-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 10px;
scrollbar-color: rgba(203, 214, 230, .5) transparent;
scrollbar-width: thin;
}
.side-nav::-webkit-scrollbar { width: 10px; }
.side-nav::-webkit-scrollbar-track,
.side-nav::-webkit-scrollbar-corner { background: transparent; }
.side-nav::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 999px; background: rgba(203, 214, 230, .5); background-clip: padding-box; }
.side-nav::-webkit-scrollbar-thumb:hover { background-color: rgba(225, 233, 244, .78); }
.side-nav::-webkit-scrollbar-button,
.side-nav::-webkit-scrollbar-button:single-button,
.side-nav::-webkit-scrollbar-button:vertical:decrement,
.side-nav::-webkit-scrollbar-button:vertical:increment { display: none; width: 0; height: 0; background: transparent; }
.side-nav-menu {
width: 100%;
border-right: 0 !important;
+12 -5
View File
@@ -255,13 +255,16 @@ onMounted(() => {
<el-button size="small" @click="loadExpressCompanies">重新查询</el-button>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" @search="applyFilters">
<template #actions>
<el-button v-if="access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</template>
</QueryToolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<div class="panel-heading">
<div>
<h2>快递公司列表</h2>
<p>共 {{ total }} 家快递公司</p>
</div>
<el-button v-if="access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -343,4 +346,8 @@ onMounted(() => {
.tracking-template { display: block; max-width: 100%; color: #475467; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; }
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.form-help code { color: #475467; font-family: "Cascadia Code", Consolas, monospace; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; }
}
</style>
+2 -1
View File
@@ -242,8 +242,9 @@ onMounted(loadUsers)
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadUsers">重新加载</el-button></template>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="内部用户筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template></QueryToolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="内部用户筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>内部用户 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>数据和维护操作均由平台 IAM 提供,角色关系按成员维度保存。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="userColumns" :loading="loading" :total="filteredRows.length" row-key="membershipId" empty-text="没有符合条件的内部用户">
<template #actions="{ row }"><div v-if="canEdit || canChangeStatus || canResetPassword || canGrantRoles" class="table-actions"><el-button v-if="canEdit" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openEdit(row as PlatformInternalUser)"><Pencil :size="15" />编辑</el-button><el-button v-if="canGrantRoles" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openRoleAssignment(row as PlatformInternalUser)"><ShieldCheck :size="15" />分配角色</el-button><el-button v-if="canChangeStatus" link :type="(row as PlatformInternalUser).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" :disabled="isCurrentUser(row as PlatformInternalUser)" :title="isCurrentUser(row as PlatformInternalUser) ? '不能停用当前登录用户' : undefined" @click="toggleStatus(row as PlatformInternalUser)">{{ (row as PlatformInternalUser).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canResetPassword" link type="warning" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="resetPassword(row as PlatformInternalUser)"><KeyRound :size="15" />重置密码</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #roles="{ row }"><div class="tag-list"><el-tag v-for="roleId in (row as PlatformInternalUser).roleIds" :key="roleId" effect="plain" size="small">{{ roleLabel(roleId) }}</el-tag><span v-if="(row as PlatformInternalUser).roleIds.length === 0" class="secondary-text">未分配</span></div></template>
+15 -7
View File
@@ -289,11 +289,7 @@ onMounted(loadPartners)
<template>
<div class="business-page partners-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners">
<template #actions>
<el-button v-if="access.can('platform:counterparty:create')" class="create-partner-button" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
</template>
</QueryToolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
<section v-if="loadError" class="surface-panel partners-error" role="alert">
<div>
@@ -305,6 +301,13 @@ onMounted(loadPartners)
</section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="partners-table-header">
<div class="partners-table-heading">
<h2>往来单位列表</h2>
<span>共 {{ total }} 个往来单位</span>
</div>
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -329,7 +332,7 @@ onMounted(loadPartners)
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 个往来单位</template>
<template #pagination-left />
</DataTable>
</section>
@@ -363,6 +366,10 @@ onMounted(loadPartners)
.partners-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
.partners-error strong { color: #7a271a; font-size: 13px; }
.partners-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
.partners-table-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid var(--cc-border); }
.partners-table-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
.partners-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.partners-table-heading span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.partner-cell { display: flex; align-items: center; gap: 10px; }
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.partner-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
@@ -372,8 +379,9 @@ onMounted(loadPartners)
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
@media (max-width: 720px) {
.partners-page :deep(.query-toolbar__actions .create-partner-button) { grid-column: 1 / -1; width: 100%; }
.partners-error { align-items: stretch; flex-direction: column; }
.partners-error .el-button { min-height: 44px; }
.partners-table-header { align-items: stretch; flex-direction: column; }
.partners-table-header .el-button { width: 100%; min-height: 40px; }
}
</style>
+2 -2
View File
@@ -68,8 +68,8 @@ describe('PermissionsView IAM mode', () => {
await flushPromises()
expect(listPlatformPermissions).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('权限资源维护已接入 IAM')
expect(wrapper.text()).toContain('系统内置资源不可修改')
expect(wrapper.text()).toContain('权限资源树')
expect(wrapper.text()).toContain('共 1 项')
})
it('shows a recoverable state when the permission list fails', async () => {
+5 -2
View File
@@ -232,10 +232,10 @@ onMounted(loadPermissions)
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template></QueryToolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<div class="panel-heading"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></div>
<header class="panel-heading"><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div><div class="permission-tree-heading-actions"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></header>
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
@@ -267,13 +267,16 @@ onMounted(loadPermissions)
<style scoped>
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
.permission-tree-heading-actions { display: flex; align-items: center; gap: 12px; }
.tree-view-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
@media (max-width: 720px) {
.permission-tree-heading-actions { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-heading-actions > .el-button { min-height: 40px; }
}
@media (max-width: 420px) {
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<div class="business-page prototype-module-page">
<section class="surface-panel prototype-module-panel">
<div>
<h2>{{ title }}</h2>
<p>{{ description }}</p>
</div>
<div class="prototype-module-empty" role="status">
<strong>暂无业务数据</strong>
<span>页面基础已就绪,后续按对应业务契约接入。</span>
</div>
</section>
</div>
</template>
<style scoped>
.prototype-module-panel { display: grid; min-height: 260px; align-content: start; gap: 28px; padding: 24px; }
.prototype-module-panel h2 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.4; }
.prototype-module-panel p { margin: 8px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.65; }
.prototype-module-empty { display: grid; min-height: 120px; place-items: center; align-content: center; gap: 6px; border: 1px dashed var(--cc-border); color: var(--cc-muted); text-align: center; }
.prototype-module-empty strong { color: var(--cc-text); font-size: 14px; }
.prototype-module-empty span { font-size: 13px; }
</style>
+2 -1
View File
@@ -233,9 +233,10 @@ onMounted(loadRoles)
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadRoles">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template></QueryToolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>角色与授权 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
+14 -6
View File
@@ -349,17 +349,21 @@ onMounted(() => {
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses">
<template #actions>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div>
<h2>平台收货仓</h2>
<p>租户 {{ fixedTenantId }} · 共 {{ filteredRows.length }} 个收货仓</p>
</div>
<el-button
v-if="access.can('platform:warehouse:create')"
type="primary"
:disabled="!warehouseReady"
@click="openCreate"
><Plus :size="16" />新增仓库</el-button>
</template>
</QueryToolbar>
<section class="surface-panel table-panel" aria-live="polite">
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -420,4 +424,8 @@ onMounted(() => {
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; }
}
</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import PrototypeModuleView from '@/views/PrototypeModuleView.vue'
defineProps<{ title: string; description: string }>()
</script>
<template><PrototypeModuleView v-bind="$props" /></template>