20260904232445

This commit is contained in:
oneao committed 2026-09-04 23:24:45 +08:00
1 parent 4e167df0f0
commit 1b01a31252
19 files changed
+1099 -1013

No files matched your search

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
+21 -110
View File
@@ -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'
}
} }
}) })
+151
View File
@@ -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'),
)
+120
View File
@@ -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>
-1
View File
@@ -230,7 +230,6 @@ describe('Input 组件', () => {
- [ ] `dashboard`:渲染、菜单/权限入口 - [ ] `dashboard`:渲染、菜单/权限入口
- [ ] `detail`:id 渲染、计数交互 - [ ] `detail`:id 渲染、计数交互
- [ ] `demo`:各组件演示区基本渲染 - [ ] `demo`:各组件演示区基本渲染
- [ ] `test/TestPage`:基本渲染
### 4.6 集成 ### 4.6 集成
+150
View File
@@ -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)
})
})
+2 -2
View File
@@ -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()
}) })
@@ -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 拼接和隐含的字段对应关系。