20260904232445
This commit is contained in:
1 parent
4e167df0f0
commit
1b01a31252
19 files changed
+1099
-1013
No files matched your search
+317
-725
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -168,6 +168,8 @@
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
color: #b0b7c3;
|
color: #b0b7c3;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
/* 从手柄按下时不选中行文本(拖拽由 mousedown 启动,不能 preventDefault) */
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.tree-node-grip:active {
|
.tree-node-grip:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
|||||||
@@ -109,13 +109,42 @@ function onContentClick(e) {
|
|||||||
function onCheckChange() {
|
function onCheckChange() {
|
||||||
ctx.handleCheck(key.value, props.node)
|
ctx.handleCheck(key.value, props.node)
|
||||||
}
|
}
|
||||||
|
/* ---------- 拖拽:仅最左侧 grip 手柄可发起 ---------- */
|
||||||
|
// 行默认 draggable=false。整行可拖时,「按下后手抖几像素」就会误起原生拖拽,
|
||||||
|
// 而松手若没落在合法落点上不会走 drop,行上又没绑 dragend → dragKey 永远清不掉,
|
||||||
|
// 节点一直挂着 is-dragging(opacity .4),表现就是「点了发灰、移开也灰」。
|
||||||
|
const gripArmed = ref(false)
|
||||||
|
const draggableAttr = computed(() =>
|
||||||
|
ctx.props.draggable && !disabled.value && gripArmed.value ? 'true' : 'false',
|
||||||
|
)
|
||||||
|
|
||||||
|
/** grip 按下:武装本行拖拽(同步写 DOM,dragstart 可能早于 Vue 的 patch) */
|
||||||
|
function armDrag() {
|
||||||
|
if (disabled.value || !ctx.props.draggable) return
|
||||||
|
gripArmed.value = true
|
||||||
|
rowRef.value?.setAttribute('draggable', 'true')
|
||||||
|
// 按下但没真拖(不会产生 dragend)时的兜底解除
|
||||||
|
document.addEventListener('mouseup', disarmDrag, { once: true })
|
||||||
|
}
|
||||||
|
function disarmDrag() {
|
||||||
|
if (!gripArmed.value) return
|
||||||
|
gripArmed.value = false
|
||||||
|
rowRef.value?.setAttribute('draggable', 'false')
|
||||||
|
}
|
||||||
function onDragStart(e) {
|
function onDragStart(e) {
|
||||||
if (disabled.value) {
|
if (disabled.value || !gripArmed.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.onDragStart(props.node, e)
|
ctx.onDragStart(props.node, e)
|
||||||
}
|
}
|
||||||
|
/** 拖拽结束(无论是否落在合法落点):先解除武装,再让容器清理拖拽态 */
|
||||||
|
function onDragEnd(e) {
|
||||||
|
disarmDrag()
|
||||||
|
ctx.onDragEnd(e)
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('mouseup', disarmDrag))
|
||||||
|
|
||||||
function onDragOver(e) {
|
function onDragOver(e) {
|
||||||
ctx.onDragOver(props.node, e)
|
ctx.onDragOver(props.node, e)
|
||||||
}
|
}
|
||||||
@@ -143,16 +172,23 @@ function onContextMenu(e) {
|
|||||||
:aria-expanded="leaf ? undefined : expanded"
|
:aria-expanded="leaf ? undefined : expanded"
|
||||||
:aria-disabled="disabled || undefined"
|
:aria-disabled="disabled || undefined"
|
||||||
:tabindex="tabindex"
|
:tabindex="tabindex"
|
||||||
:draggable="ctx.props.draggable && !disabled ? 'true' : undefined"
|
:draggable="draggableAttr"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
|
@dragend="onDragEnd"
|
||||||
@contextmenu.stop="onContextMenu"
|
@contextmenu.stop="onContextMenu"
|
||||||
>
|
>
|
||||||
<span v-if="ctx.props.draggable" class="tree-node-grip" :aria-hidden="true"
|
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable) -->
|
||||||
><GripVertical
|
<span
|
||||||
/></span>
|
v-if="ctx.props.draggable"
|
||||||
|
class="tree-node-grip"
|
||||||
|
title="拖拽调整顺序"
|
||||||
|
@mousedown="armDrag"
|
||||||
|
>
|
||||||
|
<GripVertical :aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- 展开/收起箭头:叶子占位对齐;懒加载中显示转圈 -->
|
<!-- 展开/收起箭头:叶子占位对齐;懒加载中显示转圈 -->
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Settings,
|
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { usePermissionStore } from '@/stores/permissions'
|
import { useMenuStore } from '@/stores/menus'
|
||||||
import { getIcon } from '@/utils/lucideIcons'
|
import { getIcon } from '@/utils/lucideIcons'
|
||||||
import { buildTree, sortTree } from '@/utils/tree'
|
|
||||||
import { Dropdown } from '@/components/ui'
|
import { Dropdown } from '@/components/ui'
|
||||||
import NavMain from './NavMain.vue'
|
import NavMain from './NavMain.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const permissionStore = usePermissionStore()
|
const menuStore = useMenuStore()
|
||||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||||
|
|
||||||
/* ---- 团队切换 ---- */
|
/* ---- 团队切换 ---- */
|
||||||
@@ -29,76 +27,44 @@ const teams = [
|
|||||||
]
|
]
|
||||||
const activeTeam = ref(teams[0])
|
const activeTeam = ref(teams[0])
|
||||||
|
|
||||||
/* ---- 动态导航菜单(权限模块构建树) ---- */
|
/* ---- 导航菜单:固定入口(工作台/组件库)+ s_menu 动态菜单 ---- */
|
||||||
const navMain = computed(() => {
|
const navMain = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ title: '工作台', url: '/dashboard', icon: LayoutGrid },
|
{ title: '工作台', url: '/dashboard', icon: LayoutGrid },
|
||||||
{ title: '组件库', url: '/demo', icon: Component },
|
{ title: '组件库', url: '/demo', icon: Component },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 测试页分组:方便演练 Tabs 导航(无权限依赖)
|
// 动态菜单(s_menu):由「菜单管理」维护的导航树追加渲染。
|
||||||
items.push({
|
// 可见性已在菜单 store 裁剪:非 g3soft 隐藏编码 module 的菜单,空目录不显示
|
||||||
title: '测试页面',
|
for (const node of menuStore.visibleMenus) {
|
||||||
icon: LayoutGrid,
|
const item = toMenuItem(node)
|
||||||
items: [
|
|
||||||
{ title: '列表', url: '/test/list' },
|
|
||||||
{ title: '表单', url: '/test/form' },
|
|
||||||
{ title: '详情', url: '/test/detail' },
|
|
||||||
{ title: '图表', url: '/test/chart' },
|
|
||||||
{ title: '表格', url: '/test/table' },
|
|
||||||
{ title: '设置', url: '/test/settings' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 系统管理分组(先静态配置,动态菜单/权限后续接入)
|
|
||||||
items.push({
|
|
||||||
title: '系统管理',
|
|
||||||
icon: Settings,
|
|
||||||
items: [
|
|
||||||
{ title: '模块管理', url: '/module/module-management' },
|
|
||||||
{ title: '菜单管理', url: '/module/menu-management' },
|
|
||||||
{ title: '多语言类型', url: '/module/i18n-type' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const menuModules = permissionStore.modules.filter(
|
|
||||||
(m) => m.b_canmenu === 1 && m.b_canuse === 1 && ['directory', 'page'].includes(m.b_module_type),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!menuModules.length) return items
|
|
||||||
|
|
||||||
// 仅保留父节点(若有)也在菜单中的模块
|
|
||||||
const allowedIds = new Set(menuModules.map((m) => String(m.b_id)))
|
|
||||||
const valid = menuModules.filter(
|
|
||||||
(m) => m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cloned = JSON.parse(JSON.stringify(valid))
|
|
||||||
const tree = buildTree(cloned)
|
|
||||||
sortTree(tree)
|
|
||||||
|
|
||||||
for (const node of tree) {
|
|
||||||
const item = toNavItem(node)
|
|
||||||
if (item) items.push(item)
|
if (item) items.push(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
function toNavItem(node) {
|
/** s_menu 动态菜单节点 → NavMenuItem 结构(目录/页面/外链,见 NavMenuItem.vue JSDoc) */
|
||||||
const isDir = node.b_module_type === 'directory'
|
function toMenuItem(node) {
|
||||||
// 与旧系统一致:仅目录项读取图标,页面项只显示文字
|
const type = node.b_menu_type
|
||||||
const icon = isDir ? getIcon(node.b_icon) : null
|
// 页面/外链缺路由地址时不可导航,不渲染(菜单管理保存时已校验必填,这里兜底)
|
||||||
|
if ((type === 'page' || type === 'external') && !String(node.b_route || '').trim()) return null
|
||||||
|
|
||||||
const item = {
|
const item = { title: node.b_name || node.b_code }
|
||||||
title: node.b_name || node.b_code,
|
if (type === 'external') {
|
||||||
url: node.b_route ? String(node.b_route) : `#${node.b_code}`,
|
item.external = true
|
||||||
|
item.url = String(node.b_route).trim()
|
||||||
|
} else {
|
||||||
|
item.url = type === 'directory' ? '#' : String(node.b_route).trim()
|
||||||
}
|
}
|
||||||
|
if (node.b_code) item.code = String(node.b_code)
|
||||||
|
|
||||||
|
const icon = getIcon(node.b_icon)
|
||||||
if (icon) item.icon = icon
|
if (icon) item.icon = icon
|
||||||
if (!isDir && node.b_code) item.moduleId = String(node.b_code)
|
|
||||||
|
|
||||||
if (node.children?.length) {
|
if (node.children?.length) {
|
||||||
item.items = node.children.map((c) => toNavItem(c)).filter(Boolean)
|
const children = node.children.map((c) => toMenuItem(c)).filter(Boolean)
|
||||||
|
if (children.length) item.items = children
|
||||||
}
|
}
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const appStore = useAppStore()
|
|||||||
|
|
||||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||||
const isDir = computed(() => Boolean(props.item.items?.length))
|
const isDir = computed(() => Boolean(props.item.items?.length))
|
||||||
const active = computed(() => !isDir.value && isActive(props.item.url))
|
const isExternal = computed(() => props.item.external === true)
|
||||||
|
const active = computed(() => !isDir.value && !isExternal.value && isActive(props.item.url))
|
||||||
|
|
||||||
function isActive(url) {
|
function isActive(url) {
|
||||||
if (!url || url === '#') return false
|
if (!url || url === '#') return false
|
||||||
@@ -75,6 +76,22 @@ function onToggle() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 叶子项:外链(动态菜单 external 类型,新窗口打开) -->
|
||||||
|
<a
|
||||||
|
v-else-if="item.external"
|
||||||
|
class="fms-nav-item fms-nav-link"
|
||||||
|
:href="item.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
:title="collapsed ? item.title : undefined"
|
||||||
|
>
|
||||||
|
<span v-if="item.icon" class="fms-nav-icon"><component :is="item.icon" /></span>
|
||||||
|
<span v-else-if="collapsed" class="fms-nav-icon fms-nav-fallback">{{
|
||||||
|
item.title.slice(0, 1)
|
||||||
|
}}</span>
|
||||||
|
<span v-if="!collapsed" class="fms-nav-title">{{ item.title }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- 叶子项:路由链接 -->
|
<!-- 叶子项:路由链接 -->
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -1,122 +1,22 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
import { routes } from './routes'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { usePermissionStore } from '@/stores/permissions'
|
import { usePermissionStore } from '@/stores/permissions'
|
||||||
|
import { useMenuStore } from '@/stores/menus'
|
||||||
import { useI18nStore } from '@/stores/i18n'
|
import { useI18nStore } from '@/stores/i18n'
|
||||||
|
|
||||||
NProgress.configure({ showSpinner: false })
|
NProgress.configure({ showSpinner: false })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由实例。
|
||||||
|
* 路由表本身在 ./routes.js(菜单管理的「路由地址」下拉也要读它),
|
||||||
|
* 本文件只保留实例创建与全局守卫,避免页面组件反向依赖守卫逻辑。
|
||||||
|
*/
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes,
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/views/login/index.vue'),
|
|
||||||
meta: { title: '登录' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
component: DefaultLayout,
|
|
||||||
redirect: '/dashboard',
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
name: 'dashboard',
|
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
|
||||||
meta: { title: '工作台', componentName: 'Dashboard' },
|
|
||||||
},
|
|
||||||
// 测试页:用于演练 Tabs 导航(无需权限,纯布局验证)
|
|
||||||
{
|
|
||||||
path: '/test/list',
|
|
||||||
name: 'test-list',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-列表', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/test/form',
|
|
||||||
name: 'test-form',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-表单', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/test/detail',
|
|
||||||
name: 'test-detail',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-详情', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/test/chart',
|
|
||||||
name: 'test-chart',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-图表', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/test/table',
|
|
||||||
name: 'test-table',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-表格', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/test/settings',
|
|
||||||
name: 'test-settings',
|
|
||||||
component: () => import('@/views/test/TestPage.vue'),
|
|
||||||
meta: { title: '测试-设置', componentName: 'TestPage' },
|
|
||||||
},
|
|
||||||
// 组件库演练场:纳入业务布局(带侧栏 / 顶栏 / Tabs)
|
|
||||||
{
|
|
||||||
path: '/demo',
|
|
||||||
name: 'demo',
|
|
||||||
component: () => import('@/views/demo/index.vue'),
|
|
||||||
meta: { title: '组件库', componentName: 'Demo' },
|
|
||||||
},
|
|
||||||
// 附件组件演示
|
|
||||||
{
|
|
||||||
path: '/file-demo',
|
|
||||||
name: 'file-demo',
|
|
||||||
component: () => import('@/views/file-demo/index.vue'),
|
|
||||||
meta: { title: '附件组件演示', componentName: 'FileDemo' },
|
|
||||||
},
|
|
||||||
// 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁
|
|
||||||
// (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成)
|
|
||||||
{
|
|
||||||
path: '/detail/:id',
|
|
||||||
name: 'detail',
|
|
||||||
component: () => import('@/views/detail/DetailPage.vue'),
|
|
||||||
meta: { title: '详情', cacheByKey: true },
|
|
||||||
},
|
|
||||||
// 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入)
|
|
||||||
{
|
|
||||||
path: '/module/module-management',
|
|
||||||
name: 'module-module-management',
|
|
||||||
component: () => import('@/views/module/module-management/index.vue'),
|
|
||||||
meta: { title: '模块管理', componentName: 'ModuleManagement' },
|
|
||||||
},
|
|
||||||
// 菜单管理:占位页面,待实现
|
|
||||||
{
|
|
||||||
path: '/module/menu-management',
|
|
||||||
name: 'module-menu-management',
|
|
||||||
component: () => import('@/views/module/menu-management/index.vue'),
|
|
||||||
meta: { title: '菜单管理', componentName: 'MenuManagement' },
|
|
||||||
},
|
|
||||||
// 多语言类型:系统支持的语言清单维护(s_i18n_type)
|
|
||||||
{
|
|
||||||
path: '/module/i18n-type',
|
|
||||||
name: 'module-i18n-type',
|
|
||||||
component: () => import('@/views/module/i18n-type/index.vue'),
|
|
||||||
meta: { title: '多语言类型', componentName: 'I18nTypeManagement' },
|
|
||||||
},
|
|
||||||
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/:pathMatch(.*)*',
|
|
||||||
redirect: '/dashboard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 登录守卫:未登录访问受保护页跳 /login?redirect=xxx;已登录访问 /login 跳 /dashboard
|
// 登录守卫:未登录访问受保护页跳 /login?redirect=xxx;已登录访问 /login 跳 /dashboard
|
||||||
@@ -135,10 +35,14 @@ router.beforeEach(async (to) => {
|
|||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一在路由守卫加载权限:首屏(/dashboard)与业务页都在挂载前 await 完成,
|
// 统一在路由守卫加载权限与动态菜单:首屏(/dashboard)与业务页都在挂载前 await 完成,
|
||||||
// 菜单一次性到位、不会“跳出”;load 内部按主体去重,重复进入同一主体不重复请求
|
// 菜单一次性到位、不会“跳出”;load 内部按主体去重,重复进入同一主体不重复请求
|
||||||
const permissionStore = usePermissionStore()
|
const permissionStore = usePermissionStore()
|
||||||
await permissionStore.load().catch(() => {})
|
const menuStore = useMenuStore()
|
||||||
|
await Promise.all([
|
||||||
|
permissionStore.load().catch(() => {}),
|
||||||
|
menuStore.load().catch(() => {}),
|
||||||
|
])
|
||||||
|
|
||||||
// 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载,
|
// 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载,
|
||||||
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面
|
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面
|
||||||
@@ -149,6 +53,13 @@ router.beforeEach(async (to) => {
|
|||||||
if (moduleId && !permissionStore.canAccess(moduleId)) {
|
if (moduleId && !permissionStore.canAccess(moduleId)) {
|
||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模块中心(/module/*)是系统管理员的专用页面:非 g3soft 一律拦回工作台。
|
||||||
|
// 这是路由层的特殊判断,与菜单树无关(菜单隐藏与路由放行由同一管理员判定决定),
|
||||||
|
// 避免「菜单上看不见、手输地址却能进」的越权。
|
||||||
|
if (to.path.startsWith('/module/') && !permissionStore.isG3soft) {
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 静态路由表(唯一定义处)。
|
||||||
|
*
|
||||||
|
* 单独拆出本文件的原因:「菜单管理」的「路由地址」需要从这里派生可选页面路由,
|
||||||
|
* 若路由表仍写在 index.js 里,页面组件就会反向依赖「路由实例 + 守卫」模块,
|
||||||
|
* 形成 页面 → router/index → 守卫 → store 的循环依赖风险。
|
||||||
|
* 约定:
|
||||||
|
* - routes.js :只声明路由表与由其派生的纯数据(无副作用、可被任意模块安全 import)
|
||||||
|
* - index.js :只负责创建 router 实例、注册守卫、导出默认实例
|
||||||
|
*/
|
||||||
|
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由表。
|
||||||
|
* 受保护页面挂在 DefaultLayout 下(layout 的 meta.requiresAuth 由守卫统一校验);
|
||||||
|
* 需要按业务模块鉴权的页面,在 meta 上配 moduleId,守卫校验无权限时跳 /dashboard。
|
||||||
|
*
|
||||||
|
* 路由分组(/module、/test):父节点只写 path + redirect + children,**不写 component**。
|
||||||
|
* vue-router 的 RouterView 会跳过 matched 中没有 components 的记录,子页面因此仍渲染在
|
||||||
|
* DefaultLayout 的那个 router-view 上,keep-alive 层级不变。
|
||||||
|
* 切勿给分组节点配「内部再放一个 router-view」的容器组件:那样页面会下沉一层,
|
||||||
|
* 直接脱离 DefaultLayout 的 keep-alive,页面缓存失效。
|
||||||
|
* 子路由 path 写相对路径('module-management'),完整路径由父级拼接,
|
||||||
|
* 调整分组前缀时只需改父节点一处。
|
||||||
|
*/
|
||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/login/index.vue'),
|
||||||
|
meta: { title: '登录' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: DefaultLayout,
|
||||||
|
redirect: '/dashboard',
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
|
meta: { title: '工作台', componentName: 'Dashboard' },
|
||||||
|
},
|
||||||
|
// 组件库演练场:纳入业务布局(带侧栏 / 顶栏 / Tabs)
|
||||||
|
{
|
||||||
|
path: '/demo',
|
||||||
|
name: 'demo',
|
||||||
|
component: () => import('@/views/demo/index.vue'),
|
||||||
|
meta: { title: '组件库', componentName: 'Demo' },
|
||||||
|
},
|
||||||
|
// 附件组件演示
|
||||||
|
{
|
||||||
|
path: '/file-demo',
|
||||||
|
name: 'file-demo',
|
||||||
|
component: () => import('@/views/file-demo/index.vue'),
|
||||||
|
meta: { title: '附件组件演示', componentName: 'FileDemo' },
|
||||||
|
},
|
||||||
|
// 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁
|
||||||
|
// (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成)
|
||||||
|
{
|
||||||
|
path: '/detail/:id',
|
||||||
|
name: 'detail',
|
||||||
|
component: () => import('@/views/detail/DetailPage.vue'),
|
||||||
|
meta: { title: '详情', cacheByKey: true },
|
||||||
|
},
|
||||||
|
// 模块中心:元数据驱动的配置页集合
|
||||||
|
// 分组节点(见上方「路由分组」注释:不写 component,子页写相对路径)
|
||||||
|
{
|
||||||
|
path: '/module',
|
||||||
|
redirect: '/module/module-management',
|
||||||
|
children: [
|
||||||
|
// 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入)
|
||||||
|
{
|
||||||
|
path: 'module-management',
|
||||||
|
name: 'module-module-management',
|
||||||
|
component: () => import('@/views/module/module-management/index.vue'),
|
||||||
|
meta: { title: '模块管理', componentName: 'ModuleManagement' },
|
||||||
|
},
|
||||||
|
// 菜单管理:占位页面,待实现
|
||||||
|
{
|
||||||
|
path: 'menu-management',
|
||||||
|
name: 'module-menu-management',
|
||||||
|
component: () => import('@/views/module/menu-management/index.vue'),
|
||||||
|
meta: { title: '菜单管理', componentName: 'MenuManagement' },
|
||||||
|
},
|
||||||
|
// 多语言类型:系统支持的语言清单维护(s_i18n_type)
|
||||||
|
{
|
||||||
|
path: 'i18n-type',
|
||||||
|
name: 'module-i18n-type',
|
||||||
|
component: () => import('@/views/module/i18n-type/index.vue'),
|
||||||
|
meta: { title: '多语言类型', componentName: 'I18nTypeManagement' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 不作为菜单入口的路由:独立布局、无需登录即可访问的页面 */
|
||||||
|
const EXCLUDED_PATHS = new Set(['/login'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集可作为菜单入口的页面路由(递归展开布局容器与分组节点)。
|
||||||
|
* 过滤规则:
|
||||||
|
* - 含 children 的节点(布局容器 / 分组节点)不是最终页面,只向下递归;
|
||||||
|
* - 含动态段(:id)或通配(*)的路由无法作为固定菜单入口;
|
||||||
|
* - 登录页等独立布局页排除在外。
|
||||||
|
* @param {Array} list 路由节点数组,默认整张路由表
|
||||||
|
* @param {string} parentPath 父节点完整路径,用于拼接分组内子路由的相对路径
|
||||||
|
* @returns {{ value: string, label: string }[]} 选项(value 为完整 path)
|
||||||
|
*/
|
||||||
|
export function collectMenuRoutes(list = routes, parentPath = '') {
|
||||||
|
const result = []
|
||||||
|
for (const route of list || []) {
|
||||||
|
const rawPath = route.path || ''
|
||||||
|
// 绝对路径原样使用;相对路径(分组节点的 children)拼到父路径上
|
||||||
|
const path = rawPath.startsWith('/') ? rawPath : joinPath(parentPath, rawPath)
|
||||||
|
if (Array.isArray(route.children) && route.children.length) {
|
||||||
|
result.push(...collectMenuRoutes(route.children, path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!path.startsWith('/')) continue
|
||||||
|
if (path.includes(':') || path.includes('*')) continue
|
||||||
|
if (EXCLUDED_PATHS.has(path)) continue
|
||||||
|
const title = route.meta?.title
|
||||||
|
result.push({ value: path, label: title ? `${path}(${title})` : path })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼接父路径与子路径(相对路径形态):'/module' + 'i18n-type' → '/module/i18n-type' */
|
||||||
|
function joinPath(parentPath, childPath) {
|
||||||
|
const parent = String(parentPath || '').replace(/\/+$/, '')
|
||||||
|
const child = String(childPath || '').replace(/^\/+/, '')
|
||||||
|
if (!parent) return child ? `/${child}` : ''
|
||||||
|
return child ? `${parent}/${child}` : parent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单管理「路由地址」下拉选项:按路径排序,配合 Select 的本地包含匹配(like)筛选。
|
||||||
|
* label 同时含路径与标题,输入 "/module" 或 "模块" 都能命中。
|
||||||
|
*/
|
||||||
|
export const menuRouteOptions = collectMenuRoutes().toSorted((a, b) =>
|
||||||
|
a.value.localeCompare(b.value, 'zh-Hans-CN'),
|
||||||
|
)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { loadDataApi } from '@/services/api'
|
||||||
|
import { useAuthStore } from './auth'
|
||||||
|
import { buildTree, sortTree } from '@/utils/tree'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态导航菜单 store(侧栏菜单数据源)。
|
||||||
|
*
|
||||||
|
* 菜单树由「菜单管理」维护在 s_menu 表,含目录 / 页面 / 外链三类节点。
|
||||||
|
* 可见性规则由前端执行(不改后端):
|
||||||
|
* - g3soft(管理员)可见全部启用菜单;
|
||||||
|
* - 其他账号隐藏编码为 module 的菜单(模块管理入口)及其整棵子树;
|
||||||
|
* - 目录仅当存在可见子菜单时渲染(空目录不显示)。
|
||||||
|
* 与权限 store 一致:同一登录主体加载去重、并发请求复用、登录主体切换清空缓存。
|
||||||
|
*/
|
||||||
|
export const useMenuStore = defineStore('menus', () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
/** s_menu 原始记录(b_canuse = 1,接口按 b_xh / b_id 排序返回) */
|
||||||
|
const rawMenus = ref([])
|
||||||
|
const loadedPrincipalKey = ref('')
|
||||||
|
let loadingPromise = null
|
||||||
|
let loadingPrincipalKey = ''
|
||||||
|
let loadVersion = 0
|
||||||
|
|
||||||
|
const isG3soft = computed(() => {
|
||||||
|
return (
|
||||||
|
String(authStore.loginInfo.user?.account || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === 'g3soft'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载菜单数据;同一主体重复调用去重,并发调用共享同一请求。
|
||||||
|
*/
|
||||||
|
async function load() {
|
||||||
|
const userId = String(authStore.loginInfo.user?.id || '')
|
||||||
|
const principalKey = `${String(authStore.userInfo.orgId || '')}:${userId}`
|
||||||
|
|
||||||
|
if (!authStore.isAuthenticated) return
|
||||||
|
if (loadedPrincipalKey.value === principalKey) return
|
||||||
|
if (loadingPromise && loadingPrincipalKey === principalKey) return loadingPromise
|
||||||
|
|
||||||
|
const requestVersion = ++loadVersion
|
||||||
|
loadingPrincipalKey = principalKey
|
||||||
|
const request = loadDataApi('s_menu', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
|
||||||
|
.then((res) => {
|
||||||
|
if (requestVersion !== loadVersion) return
|
||||||
|
rawMenus.value = res.data || []
|
||||||
|
loadedPrincipalKey.value = principalKey
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestVersion !== loadVersion) return
|
||||||
|
loadingPromise = null
|
||||||
|
loadingPrincipalKey = ''
|
||||||
|
})
|
||||||
|
loadingPromise = request
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前主体可见的菜单树(s_menu 原始行结构,含 children 子树)。
|
||||||
|
*/
|
||||||
|
const visibleMenus = computed(() => {
|
||||||
|
if (!rawMenus.value.length) return []
|
||||||
|
const tree = sortTree(buildTree(JSON.parse(JSON.stringify(rawMenus.value))))
|
||||||
|
return pruneByAccess(tree)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 递归按可见性裁剪菜单树:非 g3soft 隐藏 module 菜单(含其整棵子树),并剪掉空目录 */
|
||||||
|
function pruneByAccess(nodes) {
|
||||||
|
const result = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
// 模块管理(菜单编码 module)仅 g3soft 可见
|
||||||
|
if (!isG3soft.value && String(node.b_code || '').toLowerCase() === 'module') continue
|
||||||
|
const children = node.children?.length ? pruneByAccess(node.children) : []
|
||||||
|
// 目录:没有可访问子菜单时不渲染
|
||||||
|
if (node.b_menu_type === 'directory') {
|
||||||
|
if (children.length) result.push({ ...node, children })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.push(children.length ? { ...node, children } : { ...node })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
// 仅当“正在进行中的请求属于别的主体”时才作废它(如切换账号),
|
||||||
|
// 否则保留进行中请求、只清空已加载数据,交由后续 load 结果填充。
|
||||||
|
const userId = String(authStore.loginInfo.user?.id || '')
|
||||||
|
const principalKey = `${String(authStore.userInfo.orgId || '')}:${userId}`
|
||||||
|
if (loadingPrincipalKey && loadingPrincipalKey !== principalKey) {
|
||||||
|
loadVersion++
|
||||||
|
loadingPromise = null
|
||||||
|
loadingPrincipalKey = ''
|
||||||
|
}
|
||||||
|
loadedPrincipalKey.value = ''
|
||||||
|
rawMenus.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录主体变化(退出/切换账号)时清空菜单缓存
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
authStore.userInfo.orgId,
|
||||||
|
authStore.loginInfo.user?.id,
|
||||||
|
authStore.loginInfo.user?.account,
|
||||||
|
authStore.loginInfo.token,
|
||||||
|
],
|
||||||
|
clear,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
load,
|
||||||
|
clear,
|
||||||
|
isG3soft,
|
||||||
|
rawMenus,
|
||||||
|
visibleMenus,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { Languages } from '@lucide/vue'
|
import { Languages } from '@lucide/vue'
|
||||||
import { Button, Form, FormItem, Input, Select } from '@/components/ui'
|
import { Button, Form, FormItem, Input, Select } from '@/components/ui'
|
||||||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||||||
|
import { menuRouteOptions } from '@/router/routes'
|
||||||
import { MENU_TYPE_OPTIONS } from './utils'
|
import { MENU_TYPE_OPTIONS } from './utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -30,6 +31,21 @@ const maindata = defineModel({ type: Object, default: null })
|
|||||||
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
|
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
|
||||||
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
|
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由地址候选:由静态路由表(@/router/routes)派生的页面路由,
|
||||||
|
* Select 的默认筛选即对 label / value 做包含匹配,等价于 like '%关键词%',
|
||||||
|
* 输入 "/module" 命中路径、输入 "模块" 命中标题。
|
||||||
|
* 库里已存但已不在路由表中的旧值(历史数据 / 路由已下线)补进首项,
|
||||||
|
* 否则它会变成下拉里选不到的孤立值。
|
||||||
|
*/
|
||||||
|
const routeOptions = computed(() => {
|
||||||
|
const current = String(maindata.value?.b_route ?? '').trim()
|
||||||
|
if (!current) return menuRouteOptions
|
||||||
|
return menuRouteOptions.some((option) => option.value === current)
|
||||||
|
? menuRouteOptions
|
||||||
|
: [{ value: current, label: `${current}(已不在路由表中)` }, ...menuRouteOptions]
|
||||||
|
})
|
||||||
|
|
||||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||||
const formRules = computed(() => ({
|
const formRules = computed(() => ({
|
||||||
b_code: [{ required: true, message: '请填写菜单编码' }],
|
b_code: [{ required: true, message: '请填写菜单编码' }],
|
||||||
@@ -107,9 +123,15 @@ function onMenuTypeChange(menuType) {
|
|||||||
@update:model-value="onMenuTypeChange"
|
@update:model-value="onMenuTypeChange"
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<!-- page: 路由地址 -->
|
<!-- page: 路由地址:从静态路由表选择(输入即按路径 / 标题模糊匹配) -->
|
||||||
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
|
||||||
<Input :model-value="maindata.b_route" @update:model-value="(v) => setField('b_route', v)" />
|
<Select
|
||||||
|
:model-value="maindata.b_route"
|
||||||
|
:options="routeOptions"
|
||||||
|
not-found-text="无匹配路由"
|
||||||
|
clearable
|
||||||
|
@update:model-value="(v) => setField('b_route', v)"
|
||||||
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<!-- external: 外链地址(与路由地址共用 b_route 字段) -->
|
<!-- external: 外链地址(与路由地址共用 b_route 字段) -->
|
||||||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||||||
|
|||||||
@@ -734,7 +734,11 @@ async function buildSaveData() {
|
|||||||
const newPowerRows = []
|
const newPowerRows = []
|
||||||
|
|
||||||
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
|
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
|
||||||
if (powerCode) {
|
// 目录必须整块跳过:上方分支刚把该菜单的访问权限点整条 deletes 掉,
|
||||||
|
// 这里若继续按「库里查得到」把它塞进 updates,就会去更新一个本次已被删除的行
|
||||||
|
// (同一事务内先删后改 → 0 行 → 后端抛「修改记录数异常: 0」→ 整个保存回滚,连图标也存不进去);
|
||||||
|
// 查不到时走 inserts 更糟:把本该清掉的目录权限又写回去。
|
||||||
|
if (!menuIsDirectory && powerCode) {
|
||||||
const previousCode = String(maindata_org.value?.b_code || '').trim()
|
const previousCode = String(maindata_org.value?.b_code || '').trim()
|
||||||
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
|
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
|
||||||
if (previousPowerCode && previousPowerCode !== powerCode) {
|
if (previousPowerCode && previousPowerCode !== powerCode) {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
defineOptions({ name: 'TestPage' })
|
|
||||||
import { Button } from '@/components/ui'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const title = computed(() => route.meta?.title ?? '测试页')
|
|
||||||
const desc = computed(() => route.meta?.desc ?? '')
|
|
||||||
const index = computed(() => Number(route.params.id ?? 0))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fms-test-page">
|
|
||||||
<header class="fms-test-page__head">
|
|
||||||
<h1>{{ title }}</h1>
|
|
||||||
<p v-if="desc">{{ desc }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="fms-test-page__body">
|
|
||||||
<div class="fms-test-page__card">
|
|
||||||
<p>这是用于测试 Tabs 导航的占位页面。</p>
|
|
||||||
<p>
|
|
||||||
当前路由:<code>{{ route.fullPath }}</code>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
页签索引:<strong>{{ index }}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fms-test-page__actions">
|
|
||||||
<Button type="primary" @click="$router.push(`/test/${index + 1}`)">
|
|
||||||
前往下一个测试页 ({{ index + 1 }})
|
|
||||||
</Button>
|
|
||||||
<Button @click="$router.push('/dashboard')">回到工作台</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fms-test-page {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__head h1 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__head p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--fms-text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__body {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__card {
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: var(--fms-control-radius);
|
|
||||||
background: var(--fms-card);
|
|
||||||
box-shadow: var(--fms-shadow);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__card code {
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--fms-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fms-test-page__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -230,7 +230,6 @@ describe('Input 组件', () => {
|
|||||||
- [ ] `dashboard`:渲染、菜单/权限入口
|
- [ ] `dashboard`:渲染、菜单/权限入口
|
||||||
- [ ] `detail`:id 渲染、计数交互
|
- [ ] `detail`:id 渲染、计数交互
|
||||||
- [ ] `demo`:各组件演示区基本渲染
|
- [ ] `demo`:各组件演示区基本渲染
|
||||||
- [ ] `test/TestPage`:基本渲染
|
|
||||||
|
|
||||||
### 4.6 集成
|
### 4.6 集成
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useMenuStore } from '@/stores/menus'
|
||||||
|
|
||||||
|
// mock 后端数据接口,避免真实网络请求
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
loadDataApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { loadDataApi } from '@/services/api'
|
||||||
|
|
||||||
|
const mockMenus = [
|
||||||
|
{
|
||||||
|
b_id: '1',
|
||||||
|
b_code: 'sys',
|
||||||
|
b_name: '系统',
|
||||||
|
b_menu_type: 'directory',
|
||||||
|
b_parent_id: null,
|
||||||
|
b_canuse: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b_id: '2',
|
||||||
|
b_code: 'module',
|
||||||
|
b_name: '模块管理',
|
||||||
|
b_menu_type: 'page',
|
||||||
|
b_parent_id: '1',
|
||||||
|
b_route: '/module/module-management',
|
||||||
|
b_canuse: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b_id: '3',
|
||||||
|
b_code: 'menu',
|
||||||
|
b_name: '菜单管理',
|
||||||
|
b_menu_type: 'page',
|
||||||
|
b_parent_id: '1',
|
||||||
|
b_route: '/module/menu-management',
|
||||||
|
b_canuse: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b_id: '4',
|
||||||
|
b_code: 'i18n',
|
||||||
|
b_name: '多语言类型',
|
||||||
|
b_menu_type: 'page',
|
||||||
|
b_parent_id: '1',
|
||||||
|
b_route: '/module/i18n-type',
|
||||||
|
b_canuse: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function mockSModuleData(menus) {
|
||||||
|
loadDataApi.mockImplementation((view) =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: view === 's_menu' ? menus : [],
|
||||||
|
code: 0,
|
||||||
|
success: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(account, id) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setSession({ token: 't', user: { id, account } }, 'ORG')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冲刷微任务:让 setSession 触发的 watch(clear) 先执行 */
|
||||||
|
const flush = () => Promise.resolve()
|
||||||
|
|
||||||
|
function collectCodes(nodes) {
|
||||||
|
const codes = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
codes.push(String(node.b_code))
|
||||||
|
if (node.children) codes.push(...collectCodes(node.children))
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
loadDataApi.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('menus store(s_menu 动态菜单)', () => {
|
||||||
|
it('普通用户:隐藏编码 module 的菜单(含空目录剪枝)', async () => {
|
||||||
|
mockSModuleData(mockMenus)
|
||||||
|
setUser('u1', 123)
|
||||||
|
await flush()
|
||||||
|
const menus = useMenuStore()
|
||||||
|
await menus.load()
|
||||||
|
|
||||||
|
const codes = collectCodes(menus.visibleMenus)
|
||||||
|
expect(codes).toContain('sys')
|
||||||
|
expect(codes).toContain('menu')
|
||||||
|
expect(codes).toContain('i18n')
|
||||||
|
expect(codes).not.toContain('module')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通用户:目录下仅剩 module 子项时,目录整体不渲染', async () => {
|
||||||
|
// 系统目录下只有「模块管理」这一个可用子菜单
|
||||||
|
mockSModuleData(mockMenus.filter((m) => m.b_code !== 'menu' && m.b_code !== 'i18n'))
|
||||||
|
setUser('u1', 123)
|
||||||
|
await flush()
|
||||||
|
const menus = useMenuStore()
|
||||||
|
await menus.load()
|
||||||
|
|
||||||
|
expect(menus.visibleMenus).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('g3soft:module 菜单可见', async () => {
|
||||||
|
mockSModuleData(mockMenus)
|
||||||
|
setUser('g3soft', 1)
|
||||||
|
await flush()
|
||||||
|
const menus = useMenuStore()
|
||||||
|
await menus.load()
|
||||||
|
|
||||||
|
expect(menus.isG3soft).toBe(true)
|
||||||
|
expect(collectCodes(menus.visibleMenus)).toContain('module')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一主体重复 load 不重复发请求', async () => {
|
||||||
|
mockSModuleData(mockMenus)
|
||||||
|
setUser('u1', 123)
|
||||||
|
await flush()
|
||||||
|
const menus = useMenuStore()
|
||||||
|
await menus.load()
|
||||||
|
await menus.load()
|
||||||
|
expect(loadDataApi).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换账号:缓存清空并重新加载', async () => {
|
||||||
|
mockSModuleData(mockMenus)
|
||||||
|
setUser('u1', 123)
|
||||||
|
await flush()
|
||||||
|
const menus = useMenuStore()
|
||||||
|
await menus.load()
|
||||||
|
expect(menus.rawMenus.length).toBe(mockMenus.length)
|
||||||
|
|
||||||
|
// 切换到另一个主体:watch(clear) 清空已加载数据
|
||||||
|
setUser('u2', 999)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(menus.rawMenus.length).toBe(0)
|
||||||
|
expect(menus.visibleMenus).toEqual([])
|
||||||
|
|
||||||
|
// 新主体 load 重新拉取
|
||||||
|
loadDataApi.mockClear()
|
||||||
|
await menus.load()
|
||||||
|
expect(menus.rawMenus.length).toBe(mockMenus.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -57,7 +57,7 @@ describe('路由守卫 权限加载(三场景)', () => {
|
|||||||
expect(usePermissionStore().modules.length).toBe(1)
|
expect(usePermissionStore().modules.length).toBe(1)
|
||||||
// 再次进入另一受保护页(同主体)不应重复发请求
|
// 再次进入另一受保护页(同主体)不应重复发请求
|
||||||
loadDataApi.mockClear()
|
loadDataApi.mockClear()
|
||||||
await router.push('/test/list')
|
await router.push('/demo')
|
||||||
expect(loadDataApi).not.toHaveBeenCalled()
|
expect(loadDataApi).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ describe('路由守卫 权限加载(三场景)', () => {
|
|||||||
// 切换到另一账号
|
// 切换到另一账号
|
||||||
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
|
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
|
||||||
loadDataApi.mockClear()
|
loadDataApi.mockClear()
|
||||||
await router.push('/test/list')
|
await router.push('/demo')
|
||||||
expect(loadDataApi).toHaveBeenCalled() // 新主体重新加载
|
expect(loadDataApi).toHaveBeenCalled() // 新主体重新加载
|
||||||
expect(usePermissionStore().modules.length).toBe(1)
|
expect(usePermissionStore().modules.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
// mock 后端数据接口(权限/菜单/多语言都走 loadDataApi)
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
// 复位到登录页,避免上一用例的受保护路由状态泄漏
|
||||||
|
await router.push('/login').catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
function login(account, id) {
|
||||||
|
useAuthStore().setSession({ token: 't', user: { id, account, name: account } }, 'ORG')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('路由守卫:模块中心 /module/* 仅 g3soft 可访问', () => {
|
||||||
|
it('普通用户:访问 /module/* 一律拦截回工作台', async () => {
|
||||||
|
login('001', 123)
|
||||||
|
await router.push('/module/i18n-type')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||||
|
|
||||||
|
await router.push('/module/module-management')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通用户:其它受保护页面不受影响', async () => {
|
||||||
|
login('001', 123)
|
||||||
|
await router.push('/demo')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/demo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('g3soft:模块中心页面可正常访问', async () => {
|
||||||
|
login('g3soft', 1)
|
||||||
|
await router.push('/module/i18n-type')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/module/i18n-type')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { menuRouteOptions, routes } from '@/router/routes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由分组(/module)的契约测试。
|
||||||
|
* 分组节点必须保持「无 component」:一旦给它配容器组件(内部再放 router-view),
|
||||||
|
* 页面会下沉一层、脱离 DefaultLayout 的 keep-alive,缓存静默失效。
|
||||||
|
*/
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({ history: createMemoryHistory(), routes })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('路由分组(嵌套结构)', () => {
|
||||||
|
it('分组内子路由解析出完整路径,分组节点不带可渲染组件', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/module/i18n-type')
|
||||||
|
const matched = router.currentRoute.value.matched
|
||||||
|
|
||||||
|
expect(matched.map((record) => record.path)).toEqual(['/', '/module', '/module/i18n-type'])
|
||||||
|
// 分组节点无 components:RouterView 会跳过它,子页仍渲染在 DefaultLayout 的 router-view 上
|
||||||
|
expect(matched[1].components).toBeFalsy()
|
||||||
|
// meta 由父到子合并,子记录的 componentName 决定 keep-alive 的 include 名
|
||||||
|
expect(router.currentRoute.value.meta.componentName).toBe('I18nTypeManagement')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('访问分组路径本身会重定向到首个子页(不会白屏)', async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/module')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/module/module-management')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('菜单路由选项:含分组内子路由的完整路径,排除分组节点与动态/通配路由', () => {
|
||||||
|
const values = menuRouteOptions.map((option) => option.value)
|
||||||
|
|
||||||
|
expect(values).toContain('/module/module-management')
|
||||||
|
expect(values).toContain('/module/menu-management')
|
||||||
|
expect(values).toContain('/module/i18n-type')
|
||||||
|
expect(values).toContain('/dashboard')
|
||||||
|
// 分组节点本身、详情页(:id)、404 通配、登录页都不是菜单入口
|
||||||
|
expect(values).not.toContain('/module')
|
||||||
|
expect(values).not.toContain('/detail/:id')
|
||||||
|
expect(values).not.toContain('/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -73,7 +73,8 @@ beforeEach(async () => {
|
|||||||
c.unmounted = 0
|
c.unmounted = 0
|
||||||
})
|
})
|
||||||
createTestPinia()
|
createTestPinia()
|
||||||
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'u', name: 'U' } }, 'ORG')
|
// 模块中心 /module/* 在路由层仅 g3soft 放行,缓存测试用管理员账号避免被守卫拦截
|
||||||
|
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'U' } }, 'ORG')
|
||||||
await router.push('/dashboard')
|
await router.push('/dashboard')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|||||||
+134
-25
@@ -116,7 +116,22 @@ src/components/fms-record-picker/FmsRecordPickerModal.vue
|
|||||||
- 查询条件默认使用 `s_field_query`;
|
- 查询条件默认使用 `s_field_query`;
|
||||||
- 字段标题、类型和格式使用 `s_field`。
|
- 字段标题、类型和格式使用 `s_field`。
|
||||||
|
|
||||||
动作可以另外指定展示列,但不复制一整套列表和查询配置。
|
动作不新建模块,也不复制一套字段定义。它只在来源模块的基础配置上,引用并裁剪本次选择器需要的展示列和查询项。
|
||||||
|
|
||||||
|
动作可以配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
展示列模式:inherit / explicit
|
||||||
|
查询条件模式:inherit / explicit
|
||||||
|
```
|
||||||
|
|
||||||
|
含义如下:
|
||||||
|
|
||||||
|
- `inherit`:沿用来源模块启用的 `s_field_view` 或 `s_field_query`;
|
||||||
|
- `explicit`:只使用动作配置中明确引用的展示列或查询项;
|
||||||
|
- `explicit` 且没有配置项:表示本动作明确不提供对应的展示列或用户查询项,不能再解释为“继承默认配置”。
|
||||||
|
|
||||||
|
动作字段配置只保存对既有配置的引用,不重新定义字段名称、类型、权限和操作符。来源模块仍然是字段定义的唯一来源。
|
||||||
|
|
||||||
## 四、单选、多选和重复选择是三件事
|
## 四、单选、多选和重复选择是三件事
|
||||||
|
|
||||||
@@ -181,7 +196,7 @@ append / replace multiple + forbid + warn
|
|||||||
|
|
||||||
### 查询条件到底配置在哪里
|
### 查询条件到底配置在哪里
|
||||||
|
|
||||||
客户在选择器中看到并填写的查询项,配置在**来源模块**的查询配置中,也就是现有的 `s_field_query`(模块管理中的“查询”页签)。
|
客户在选择器中看到并填写的查询项,基础配置仍然来自**来源模块**的 `s_field_query`(模块管理中的“查询”页签)。动作可以选择直接继承,也可以通过动作查询配置引用其中的子集。
|
||||||
|
|
||||||
例如来源模块 `bs_business_history` 的“查询”页签配置了:
|
例如来源模块 `bs_business_history` 的“查询”页签配置了:
|
||||||
|
|
||||||
@@ -192,7 +207,7 @@ append / replace multiple + forbid + warn
|
|||||||
客户:下拉选择
|
客户:下拉选择
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `FmsRecordPickerModal` 时,组件读取这个来源模块的查询配置,自动显示这些查询控件。客户填写后点击“查询”,这些条件只影响本次弹窗的数据列表。
|
打开 `FmsRecordPickerModal` 时,后端先按动作的 `b_query_mode` 解析有效查询项,再返回给组件渲染。客户填写后点击“查询”,这些条件只影响本次弹窗的数据列表。
|
||||||
|
|
||||||
动作配置中的 `s_module_action_filter` 是另一回事。它保存的是动作必须带上的固定条件,客户看不到,也不能修改,例如“只能查当前客户的历史记录”。
|
动作配置中的 `s_module_action_filter` 是另一回事。它保存的是动作必须带上的固定条件,客户看不到,也不能修改,例如“只能查当前客户的历史记录”。
|
||||||
|
|
||||||
@@ -209,7 +224,19 @@ s_module_action_filter 系统强制追加的固定条件
|
|||||||
来源模块查询条件 AND 动作固定条件
|
来源模块查询条件 AND 动作固定条件
|
||||||
```
|
```
|
||||||
|
|
||||||
第一版建议直接复用来源模块的 `s_field_query`,不再为每个动作单独复制一套查询字段。只有以后出现“同一个来源模块,不同动作需要显示完全不同的查询项”时,再增加动作专用查询字段配置。
|
动作查询配置只引用来源模块已有的 `s_field_query` 记录,不复制字段定义或查询语义。这样同一个来源模块可以支持多个选择器动作,每个动作使用不同的查询项集合。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
来源模块查询配置:业务单号、发货人、收货人、客户、发生日期、状态
|
||||||
|
|
||||||
|
选择历史发货人:业务单号、发货人、发生日期
|
||||||
|
选择历史收货人:业务单号、收货人、发生日期
|
||||||
|
选择历史费用:费用名称、发生日期、币种、状态
|
||||||
|
```
|
||||||
|
|
||||||
|
动作固定条件不进入用户查询区。用于固定过滤、去重和关联的技术字段可以不显示,也不能作为用户临时查询项。
|
||||||
|
|
||||||
### 1. 动作固定条件
|
### 1. 动作固定条件
|
||||||
|
|
||||||
@@ -223,7 +250,7 @@ s_module_action_filter 系统强制追加的固定条件
|
|||||||
|
|
||||||
### 2. 用户临时查询条件
|
### 2. 用户临时查询条件
|
||||||
|
|
||||||
这是用户在弹窗中输入的条件,例如费用名称、日期、状态、编码等。默认复用来源模块的 `s_field_query` 配置,使用和普通列表页面相同的查询控件。
|
这是用户在弹窗中输入的条件,例如费用名称、日期、状态、编码等。动作使用 `inherit` 时复用来源模块的 `s_field_query`;使用 `explicit` 时只显示动作引用的查询项,控件和操作符仍由被引用的来源查询配置决定。
|
||||||
|
|
||||||
例如来源模块配置了:
|
例如来源模块配置了:
|
||||||
|
|
||||||
@@ -233,7 +260,7 @@ s_module_action_filter 系统强制追加的固定条件
|
|||||||
币种:下拉选择
|
币种:下拉选择
|
||||||
```
|
```
|
||||||
|
|
||||||
选择器就显示这些查询项。用户点击查询后,条件和动作固定条件一起生效。
|
选择器按动作的有效查询项显示这些查询控件。用户点击查询后,条件和动作固定条件一起生效。
|
||||||
|
|
||||||
### 3. 前端只传结构化条件,不传 SQL
|
### 3. 前端只传结构化条件,不传 SQL
|
||||||
|
|
||||||
@@ -345,6 +372,8 @@ b_customer_id b_customer_id
|
|||||||
|
|
||||||
映射配置应从源模块和目标模块的字段元数据中选择字段,保存字段 ID,不让用户手工填写字段名。
|
映射配置应从源模块和目标模块的字段元数据中选择字段,保存字段 ID,不让用户手工填写字段名。
|
||||||
|
|
||||||
|
字段映射不是新的字段定义。来源字段和目标字段都必须引用现有 `s_field`,字段名称、类型、可空性、写入模式和启用状态始终以 `s_field` 为准。
|
||||||
|
|
||||||
每条映射还可以配置:
|
每条映射还可以配置:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -357,6 +386,23 @@ b_customer_id b_customer_id
|
|||||||
|
|
||||||
如果目标是子表,运行时根据 `s_relation` 自动补齐父 ID、临时 ID 和排序号。目标字段的只读、计算、系统字段规则仍然有效,不能通过动作配置绕过。
|
如果目标是子表,运行时根据 `s_relation` 自动补齐父 ID、临时 ID 和排序号。目标字段的只读、计算、系统字段规则仍然有效,不能通过动作配置绕过。
|
||||||
|
|
||||||
|
动作配置只能缩小字段范围,不能提升字段权限。运行时的有效字段按以下规则计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
可展示/可查询来源字段
|
||||||
|
= 动作引用的字段
|
||||||
|
∩ 来源模块中存在且启用的字段
|
||||||
|
∩ 当前用户拥有 view 权限的字段
|
||||||
|
|
||||||
|
可写目标字段
|
||||||
|
= 动作映射的字段
|
||||||
|
∩ 目标模块中存在且启用的字段
|
||||||
|
∩ 当前用户拥有 edit 权限的字段
|
||||||
|
∩ s_field.b_writemode 允许写入
|
||||||
|
```
|
||||||
|
|
||||||
|
固定过滤字段、去重主键和关系父 ID 等技术字段可以不展示给用户,但后端只能将它们用于动作执行,不能因为动作配置而把它们作为普通可见字段返回。
|
||||||
|
|
||||||
## 七、重复选择的运行规则
|
## 七、重复选择的运行规则
|
||||||
|
|
||||||
系统需要同时考虑三种“已经选过”的数据:
|
系统需要同时考虑三种“已经选过”的数据:
|
||||||
@@ -385,13 +431,13 @@ b_customer_id b_customer_id
|
|||||||
|
|
||||||
## 八、动作类型
|
## 八、动作类型
|
||||||
|
|
||||||
第一阶段只支持有限的动作类型:
|
动作类型先定义为有限集合;第一阶段只落地 `copy` 和 `append`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pick 选择一条记录,返回关联值
|
pick 选择一条记录,返回关联值
|
||||||
copy 选择一条记录,复制到当前主表
|
copy 选择一条记录,复制到当前主表
|
||||||
append 选择多条记录,追加到目标子表
|
append 选择多条记录,追加到目标子表
|
||||||
replace 选择多条记录,替换目标子表
|
replace 选择多条记录,替换目标子表(后续阶段)
|
||||||
```
|
```
|
||||||
|
|
||||||
不要一开始支持任意脚本或任意客户端函数。遇到特殊转换时,增加明确命名的转换类型或受控处理器。
|
不要一开始支持任意脚本或任意客户端函数。遇到特殊转换时,增加明确命名的转换类型或受控处理器。
|
||||||
@@ -411,15 +457,20 @@ b_name 动作名称
|
|||||||
b_module_id 目标模块
|
b_module_id 目标模块
|
||||||
b_action_type pick/copy/append/replace
|
b_action_type pick/copy/append/replace
|
||||||
b_source_module_id 来源模块
|
b_source_module_id 来源模块
|
||||||
|
b_target_module_id 结果写入的目标模块;写入当前主表时可等于目标模块
|
||||||
|
b_target_relation_id 主表到目标子表的关系;写入主表时为空
|
||||||
b_selection_mode single/multiple
|
b_selection_mode single/multiple
|
||||||
b_reselect_policy forbid/allow
|
b_reselect_policy forbid/allow
|
||||||
b_duplicate_behavior skip/warn/error
|
b_duplicate_behavior skip/warn/error
|
||||||
b_target_scope main/detail
|
|
||||||
b_dedupe_field_id 来源去重字段
|
b_dedupe_field_id 来源去重字段
|
||||||
|
b_view_mode inherit/explicit
|
||||||
|
b_query_mode inherit/explicit
|
||||||
b_canuse
|
b_canuse
|
||||||
b_xh
|
b_xh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`b_target_scope = main/detail` 不足以表达一个模块有多个子表,因此使用目标模块和目标关系确定写入位置。`b_view_mode` 与 `b_query_mode` 用于区分“未配置,继承来源模块”和“明确配置为空,不显示/不提供查询项”。
|
||||||
|
|
||||||
### `s_module_action_filter`
|
### `s_module_action_filter`
|
||||||
|
|
||||||
保存动作固定过滤条件:
|
保存动作固定过滤条件:
|
||||||
@@ -440,18 +491,32 @@ b_xh
|
|||||||
|
|
||||||
### `s_module_action_column`
|
### `s_module_action_column`
|
||||||
|
|
||||||
保存动作专用展示列覆盖:
|
保存动作引用的展示列:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
b_id
|
b_id
|
||||||
b_action_id
|
b_action_id
|
||||||
b_source_field_id
|
b_source_field_view_id 引用来源模块的 s_field_view
|
||||||
|
b_visible
|
||||||
|
b_xh
|
||||||
|
b_width_override
|
||||||
|
```
|
||||||
|
|
||||||
|
动作使用 `explicit` 模式时,只显示被引用的 `s_field_view`;`inherit` 模式下直接使用来源模块的 `s_field_view`。动作不复制字段定义、字段标题或字段权限。
|
||||||
|
|
||||||
|
### `s_module_action_query`
|
||||||
|
|
||||||
|
保存动作引用的用户查询项:
|
||||||
|
|
||||||
|
```text
|
||||||
|
b_id
|
||||||
|
b_action_id
|
||||||
|
b_source_field_query_id 引用来源模块的 s_field_query
|
||||||
b_visible
|
b_visible
|
||||||
b_width
|
|
||||||
b_xh
|
b_xh
|
||||||
```
|
```
|
||||||
|
|
||||||
没有动作列配置时,直接使用来源模块的 `s_field_view`。
|
动作使用 `explicit` 模式时,只显示被引用的 `s_field_query`;`inherit` 模式下直接使用来源模块的 `s_field_query`。查询字段的类型和操作符以被引用的来源查询配置为准,第一版不在动作表中重复定义。
|
||||||
|
|
||||||
### `s_module_action_mapping`
|
### `s_module_action_mapping`
|
||||||
|
|
||||||
@@ -470,6 +535,29 @@ b_is_dedupe_key
|
|||||||
b_xh
|
b_xh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 动作配置与字段权限的关系
|
||||||
|
|
||||||
|
动作定义和权限定义分开保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
s_module_action
|
||||||
|
描述动作如何查、如何选、如何映射
|
||||||
|
|
||||||
|
s_power
|
||||||
|
b_power_type = action
|
||||||
|
描述谁可以执行这个动作
|
||||||
|
```
|
||||||
|
|
||||||
|
动作编码使用目标模块编码和动作编码组成稳定身份,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cw_business.select_history_fee
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的 `s_power` 动作权限使用目标模块和动作编码建立关联,操作为 `execute`。动作配置不能绕过 `s_power`,字段权限也不能被动作配置提升。后端必须同时校验动作执行权限、来源字段 `view` 权限和目标字段 `edit` 权限。
|
||||||
|
|
||||||
|
来源字段被停用、删除或当前用户无权查看时,动作应在配置校验或运行时被判定为无效,不能静默删掉映射项继续执行。
|
||||||
|
|
||||||
## 十、运行时流程
|
## 十、运行时流程
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -477,7 +565,7 @@ b_xh
|
|||||||
↓
|
↓
|
||||||
业务页面上的按钮打开 FmsRecordPickerModal,并传入当前表单上下文
|
业务页面上的按钮打开 FmsRecordPickerModal,并传入当前表单上下文
|
||||||
↓
|
↓
|
||||||
读取来源模块的列表列和查询配置
|
解析动作的展示列模式和查询条件模式,引用来源模块配置的对应子集
|
||||||
↓
|
↓
|
||||||
解析动作固定条件
|
解析动作固定条件
|
||||||
↓
|
↓
|
||||||
@@ -498,7 +586,7 @@ b_xh
|
|||||||
|
|
||||||
选择器不负责保存数据库。映射结果先进入当前页面的草稿、diff 和校验流程,和手工新增的数据一样,最后统一保存。
|
选择器不负责保存数据库。映射结果先进入当前页面的草稿、diff 和校验流程,和手工新增的数据一样,最后统一保存。
|
||||||
|
|
||||||
建议选择确认事件返回记录或 patch,不返回 SQL:
|
建议选择确认事件返回记录或 patch,不返回 SQL。客户端确认时只提交来源记录的稳定主键,后端重新读取来源记录并生成 patch:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -538,10 +626,14 @@ b_xh
|
|||||||
### 动作执行器(建议放在后端或独立服务层)
|
### 动作执行器(建议放在后端或独立服务层)
|
||||||
|
|
||||||
- 读取并校验动作配置;
|
- 读取并校验动作配置;
|
||||||
|
- 解析动作专用展示列和查询项,并与来源模块配置做一致性校验;
|
||||||
- 校验用户对来源模块的访问权限;
|
- 校验用户对来源模块的访问权限;
|
||||||
|
- 校验当前用户是否拥有动作对应的 `s_power` 执行权限;
|
||||||
|
- 校验来源字段 `view` 权限、目标字段 `edit` 权限和字段写入模式;
|
||||||
- 解析当前主表、子表、用户和日期等值来源;
|
- 解析当前主表、子表、用户和日期等值来源;
|
||||||
- 校验字段 ID、目标模块和操作符;
|
- 校验字段 ID、目标模块和操作符;
|
||||||
- 生成参数化查询;
|
- 生成参数化查询;
|
||||||
|
- 根据选中的来源主键重新读取来源数据;
|
||||||
- 执行去重检查和字段映射。
|
- 执行去重检查和字段映射。
|
||||||
|
|
||||||
### 业务页面
|
### 业务页面
|
||||||
@@ -554,9 +646,12 @@ b_xh
|
|||||||
## 十二、安全和校验要求
|
## 十二、安全和校验要求
|
||||||
|
|
||||||
- 来源模块必须通过当前用户权限校验;
|
- 来源模块必须通过当前用户权限校验;
|
||||||
|
- 动作必须通过对应的 `s_power` `execute` 权限校验;
|
||||||
- 后端不能信任前端传来的任意字段、任意 SQL 或任意脚本;
|
- 后端不能信任前端传来的任意字段、任意 SQL 或任意脚本;
|
||||||
- 过滤条件必须使用字段 ID、操作符和值的结构化表达;
|
- 过滤条件必须使用字段 ID、操作符和值的结构化表达;
|
||||||
- 后端按字段类型限制可用操作符;
|
- 后端按字段类型限制可用操作符;
|
||||||
|
- 动作展示列和查询项只能引用来源模块中存在且启用的 `s_field_view` / `s_field_query`;
|
||||||
|
- 动作配置只能缩小来源模块字段范围,不能提升字段 `view` / `edit` 权限;
|
||||||
- 映射目标必须属于目标模块或目标子模块;
|
- 映射目标必须属于目标模块或目标子模块;
|
||||||
- `readOnly`、`computed`、`system` 字段的写入规则继续生效;
|
- `readOnly`、`computed`、`system` 字段的写入规则继续生效;
|
||||||
- 来源字段删除或停用后,动作配置显示为无效并禁止启用;
|
- 来源字段删除或停用后,动作配置显示为无效并禁止启用;
|
||||||
@@ -578,6 +673,8 @@ b_xh
|
|||||||
- 主表字段映射;
|
- 主表字段映射;
|
||||||
- 子表追加;
|
- 子表追加;
|
||||||
- `forbid` 和 `allow` 两种重复选择策略;
|
- `forbid` 和 `allow` 两种重复选择策略;
|
||||||
|
- 动作展示列和查询条件的 `inherit / explicit` 两种模式;
|
||||||
|
- 来源字段和目标字段权限交集校验;
|
||||||
- 分页和重新打开弹窗后的已选状态。
|
- 分页和重新打开弹窗后的已选状态。
|
||||||
|
|
||||||
### 第二阶段:增加数据库配置
|
### 第二阶段:增加数据库配置
|
||||||
@@ -587,9 +684,10 @@ b_xh
|
|||||||
- `s_module_action`;
|
- `s_module_action`;
|
||||||
- `s_module_action_filter`;
|
- `s_module_action_filter`;
|
||||||
- `s_module_action_column`;
|
- `s_module_action_column`;
|
||||||
|
- `s_module_action_query`;
|
||||||
- `s_module_action_mapping`。
|
- `s_module_action_mapping`。
|
||||||
|
|
||||||
同时增加动作配置的启用校验和无效字段提示。
|
动作列和动作查询项只引用来源模块已有的 `s_field_view` / `s_field_query`,不复制字段定义。同步增加动作配置的启用校验、字段权限校验和无效字段提示。
|
||||||
|
|
||||||
### 第三阶段:迁移业务操作
|
### 第三阶段:迁移业务操作
|
||||||
|
|
||||||
@@ -601,19 +699,30 @@ b_xh
|
|||||||
4. 选择历史费用;
|
4. 选择历史费用;
|
||||||
5. 选择箱子或其他业务子表记录。
|
5. 选择箱子或其他业务子表记录。
|
||||||
|
|
||||||
迁移完成后,删除页面中按业务名称编写的专用选择弹窗和字段匹配代码。
|
`replace`、复合去重键、`OR` 条件和自定义脚本转换不作为第一阶段的必要能力。迁移完成后,删除页面中按业务名称编写的专用选择弹窗和字段匹配代码。
|
||||||
|
|
||||||
## 十四、仍需确定的事项
|
## 十四、已确定和仍需确定的事项
|
||||||
|
|
||||||
- 动作按钮是否只复用模块权限,还是需要单独的动作权限;
|
### 已确定
|
||||||
- `append` 追加时重复记录默认提示后跳过,还是直接阻止确认;
|
|
||||||
- `replace` 替换时是否需要保留被替换行的来源关系;
|
- 来源模块是数据、字段和基础查询配置的唯一来源,不为每个选择器复制模块;
|
||||||
- 是否允许业务配置自定义去重字段组合,而不只是单个主键;
|
- 动作只引用并裁剪来源模块已有的 `s_field_view` / `s_field_query`;
|
||||||
- 特殊字段转换是否需要一个受控的转换器注册表;
|
- 动作展示列和查询项通过 `inherit / explicit` 明确区分继承和显式空配置;
|
||||||
|
- 字段权限由 `s_power` 的 `field/view/edit` 规则统一控制,动作配置不能提升权限;
|
||||||
|
- 动作执行权限使用独立的 `s_power` `action/execute` 权限;
|
||||||
|
- 第一版优先实现 `fill/copy` 和 `append`,`replace` 延后;
|
||||||
|
- 固定过滤、去重和关系字段可以隐藏,但不能作为普通可见数据返回前端。
|
||||||
|
|
||||||
|
### 仍需确定
|
||||||
|
|
||||||
|
- `append` 追加时重复记录默认采用 `warn + skip`,还是直接阻止确认;
|
||||||
|
- `replace` 后续是否保留被替换行的来源关系;
|
||||||
|
- 是否允许复合去重键,而不只是单个稳定主键;
|
||||||
|
- 特殊字段转换是否需要受控的转换器注册表;
|
||||||
- 是否需要动作配置的预览和测试执行功能;
|
- 是否需要动作配置的预览和测试执行功能;
|
||||||
- 来源模块是视图时,如何配置稳定主键;
|
- 来源模块为视图且 `b_keyfield` 不可靠时,是否允许配置受控的复合稳定键;
|
||||||
- 业务页面是否允许少量自定义动作插槽。
|
- 业务页面是否允许少量自定义动作插槽。
|
||||||
|
|
||||||
## 十五、一句话总结
|
## 十五、一句话总结
|
||||||
|
|
||||||
把“历史记录选择”当成一种可配置的业务动作:配置决定查什么、怎么筛、能不能重复选、选中后写到哪里;通用选择器负责展示和选择;业务页面只接收结果并统一保存。这样既能复用现有模块元数据,也能避免页面代码里出现 SQL 拼接和隐含的字段对应关系。
|
把“历史记录选择”当成一种可配置的业务动作:来源模块负责数据和字段定义,动作决定本次选择器展示哪些列、提供哪些查询项、怎么筛、能不能重复选以及选中后写到哪里;字段权限和动作执行权限由权限模型统一校验;通用选择器负责展示和选择;业务页面只接收结果并统一保存。这样无需为不同选择器复制模块,也能避免页面代码里出现 SQL 拼接和隐含的字段对应关系。
|
||||||
Reference in new issue
Block a user