This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
1 parent b4498e5350
commit bf9dca19de
47 files changed
+8592 -313

No files matched your search

@@ -27,10 +27,11 @@
* expanded-keys 以 v-model:expanded-keys 暴露:搜索开始 / 结束时面板会抛出「全部展开 / []」,
* 用以保证命中项可见;受控与否都生效(不传时面板用内部状态兜住)。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { ChevronsLeft, ChevronsRight, Plus, Search, X } from '@lucide/vue'
import { G3Button, G3Input, G3Scrollbar } from '@g3soft/ui'
import type { TreeKey } from '@g3soft/ui'
import { useHotkeys } from '@/composables/useHotkeys'
import { collectAllKeys, filterTreeByText, type TreeRow } from '@/utils/tree'
import BizTree from './BizTree.vue'
@@ -160,25 +161,22 @@ function splitByKeyword(text: string): TitleSegment[] {
/**
* 面板快捷键:Esc 关闭搜索;/ 在非输入状态下聚焦搜索(浏览器里 / 是「站内查找」的惯例键)。
* 挂在 document 上才能覆盖「焦点在树里」等场景;单页一个树面板,不存在互抢。
* 经 useHotkeys 注册在 window 捕获阶段,覆盖「焦点在树里」等场景;
* 输入态判断统一由 useHotkeys 负责(input / textarea / select / contenteditable)。
*/
function onGlobalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && searchMode.value) {
closeSearch()
return
}
if (event.key !== '/' || searchMode.value) return
const target = event.target as HTMLElement | null
const inInput =
target?.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes((target?.tagName ?? '').toUpperCase())
if (inInput) return
event.preventDefault()
void openSearch()
}
onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown))
useHotkeys([
{
key: 'Escape',
// 只在搜索态接管:否则会把 Esc 一直吞掉,弹窗关闭等行为跟着失效
when: () => searchMode.value,
handler: () => closeSearch(),
},
{
key: '/',
when: () => !searchMode.value,
handler: () => void openSearch(),
},
])
/* ================= 展开 ================= */
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* 图标输入:文本输入 + 预览(对照 fms-vue 的 components/icon-picker/IconPicker.vue)。
*
* 两种用法:
* - 输入 lucide 图标名(如 `chevron-down` / `lucide:chevron-down`)→ 渲染对应图标
* - 输入其它内容 → 仅当是一段合法 SVG 标记才渲染(支持粘贴自定义图标)
* 解析规则集中在 @/utils/iconResolver,与侧栏 / 菜单树的 getMenuIcon 同源。
*
* 为什么是自由文本而不是下拉选择:
* 图标集有 6000+ 项,做成下拉既卡顿又无法浏览;而行业专有图标
* (船公司 logo 之类)根本不在 lucide 里,只能靠粘贴 SVG 补齐。
* 代价是拼错时不会当场发现——所以这里额外把「无效」显式提示出来,
* 而不是像 fms 那样静默不渲染。
*
* 预览只在失焦后更新:输入过程中逐字符重建 SVG 会闪烁,
* 而 SVG 源码在没写完时本来就是无效的,边打边渲染没有意义。
*/
import { computed, ref, watch } from 'vue'
import { G3Input } from '@g3soft/ui'
import { getIcon, isValidIconValue } from '@/utils/iconResolver'
const props = withDefaults(
defineProps<{
/** 双向绑定的图标值(lucide 图标名 或 SVG 标记字符串) */
modelValue?: string
/** 禁用态 */
disabled?: boolean
}>(),
{ modelValue: '', disabled: false },
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
/**
* 预览基于**已提交**(失焦)的值:输入过程中不实时渲染,
* 避免 SVG 逐字符重建闪烁。
*/
const committed = ref(props.modelValue)
watch(
() => props.modelValue,
(value) => {
if (value === committed.value) return
// 外部赋值(切换菜单 / 重载)时立即同步预览;仅用户输入时不更新
committed.value = value
},
)
function onBlur() {
committed.value = props.modelValue
}
const previewIcon = computed(() => getIcon(committed.value))
/** 已提交的值是否无效(非空但解析不出图标)—— 让它当场可见,而不是静默没图标 */
const invalid = computed(
() => String(committed.value ?? '').trim() !== '' && !isValidIconValue(committed.value),
)
</script>
<template>
<div class="icon-picker" :class="{ 'is-disabled': disabled }">
<div class="icon-picker__field">
<G3Input
:model-value="modelValue"
:disabled="disabled"
clearable
@update:model-value="(value: string | number) => emit('update:modelValue', String(value))"
@blur="onBlur"
/>
</div>
<!-- 预览:解析得出的图标;解析不出时给出「无效」提示 -->
<div v-if="previewIcon" class="icon-picker__preview" :title="committed">
<component :is="previewIcon" class="icon-picker__preview-icon" />
</div>
<span v-else-if="invalid" class="icon-picker__invalid" title="既不是 lucide 图标名,也不是合法的 SVG 标记">
无效
</span>
</div>
</template>
<style lang="scss" scoped>
.icon-picker {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.icon-picker__field {
flex: 1;
min-width: 0;
}
/* 预览位固定 28×28:有无图标都不改变行高,避免切换菜单时表单跳动 */
.icon-picker__preview {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--g3-color-text, #1f2329);
:deep(svg) {
width: 18px;
height: 18px;
}
}
/* 无效提示:与预览同宽,占位一致,不引起布局跳动 */
.icon-picker__invalid {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--g3-color-danger, #f53f3f);
font-size: 11px;
}
</style>
@@ -0,0 +1,125 @@
import { onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
/**
* 全局键盘快捷键注册(可组合、可复用)。
*
* 移植自 fms-vue 的 src/composables/useHotkeys.js,两处按本项目口径调整:
* 1. 输入态判断统一走 closest(...)(原版只比 e.target 自身的 tagName):
* 原版漏了 SELECT,且在 input 内层元素上触发时判不出来;
* 本仓库设计器侧本来就是 closest 口径,这里对齐成同一份,避免两套标准。
* 2. 用 TS 声明参数,调用点写错键名/漏 handler 会在 typecheck 阶段报错。
*
* 用法(在任意组件 setup 顶层调用):
* useHotkeys([
* { key: 's', mod: true, ignoreWhenTyping: false, handler: save }, // Ctrl/Cmd + S
* { key: '/', handler: focusSearch }, // 单独键(无修饰符)
* { key: 'r', alt: true, handler: reload }, // Alt + R
* ])
*
* 修饰符语义(精确匹配,未声明的修饰符按下则不匹配):
* mod —— 主修饰键:Ctrl 或 Meta(Cmd)任一(跨平台主修饰键)
* ctrl / meta / alt / shift —— 单独指定(与 mod 可叠加,如 { key: 's', mod: true, shift: true })
*
* 其他选项:
* ignoreWhenTyping(默认 true)—— 焦点在 input / textarea / select / 可编辑区域时忽略;
* 组合保存类快捷键(如 Ctrl+S)通常传 false,保证输入时也能保存。
* handler(e) —— 触发回调,默认已 preventDefault 阻止浏览器默认行为。
*/
/** 输入态选择器:这些元素内的按键留给文本编辑自己处理 */
const TYPING_SELECTOR = 'input, textarea, select, [contenteditable="true"]'
export interface Hotkey {
/** 主键,不区分大小写;如 's'、'/'、'ArrowLeft'、'Escape' */
key: string
/** 主修饰键:Ctrl 或 Meta 任一。与 ctrl / meta / alt / shift 可叠加 */
mod?: boolean
ctrl?: boolean
meta?: boolean
alt?: boolean
shift?: boolean
/** 焦点在输入框时是否忽略,默认 true */
ignoreWhenTyping?: boolean
/**
* 前置条件:返回 false 表示「这次不归我管」——既不调用 handler,也不 preventDefault。
*
* 用于键位只有在特定状态下才该生效的场景。典型是 Esc:
* 它在全局始终可匹配,若不加条件就会把 Esc 一直吞掉,
* 连带干掉弹窗关闭这类浏览器/组件自身的 Esc 行为。
*/
when?: () => boolean
/** 触发回调(已 preventDefault) */
handler: (e: KeyboardEvent) => void
}
/** 输入态判断:焦点在 input / textarea / select / 可编辑区域内 */
function isTypingTarget(e: KeyboardEvent): boolean {
const el = e.target as HTMLElement | null
if (!el || typeof el.closest !== 'function') return false
return el.closest(TYPING_SELECTOR) !== null
}
/**
* 主键匹配:优先用 e.key;中文输入法组合中(拼音尚未上屏)时 e.key 为
* 'Process' / 'Unidentified',此时退回物理键位 e.code('KeyS' → 's')兜底。
* 否则「输入法激活时按 Ctrl+S」会概率性不匹配,表现为快捷键时灵时不灵。
*/
function keyMatches(e: KeyboardEvent, key: string): boolean {
const target = key.toLowerCase()
if (String(e.key || '').toLowerCase() === target) return true
const code = String(e.code || '')
return code.startsWith('Key') && code.slice(3).toLowerCase() === target
}
/** 快捷键匹配:精确匹配(未声明的修饰符按下则不匹配) */
function matches(e: KeyboardEvent, s: Hotkey): boolean {
if (!keyMatches(e, s.key)) return false
// 主修饰键:声明了 mod / ctrl / meta 中任一 → 要求 Ctrl 或 Meta 按下
const needsMod = s.mod === true || s.ctrl === true || s.meta === true
const modPressed = e.ctrlKey || e.metaKey
if (needsMod !== modPressed) return false
// alt / shift 精确匹配(未声明即要求未按下)
if (!!s.alt !== e.altKey) return false
if (!!s.shift !== e.shiftKey) return false
return true
}
/**
* 生命周期:
* - 监听注册在 window 的**捕获阶段**:后代元素上的 @keydown.stop / stopPropagation
* 只阻断冒泡,捕获阶段仍能收到,避免个别控件(如结构树重命名输入框)内快捷键失效;
* - onMounted 注册、onBeforeUnmount 卸载;
* - keep-alive 缓存的页面切走后实例仍存活,故 onDeactivated 解绑、onActivated 重新绑定:
* 否则同时打开的多个页签(如模块管理 / 菜单管理)会一起响应同一次 Ctrl+S 而互相干扰。
*/
export function useHotkeys(shortcuts: Hotkey[]): void {
function onKeydown(e: KeyboardEvent): void {
for (const s of shortcuts) {
if (!matches(e, s)) continue
if (s.ignoreWhenTyping !== false && isTypingTarget(e)) continue
// 条件不满足即让行:不拦截,留给别处处理(见 when 的说明)
if (s.when && !s.when()) continue
e.preventDefault()
s.handler(e)
break
}
}
let bound = false
function bind(): void {
if (bound) return
window.addEventListener('keydown', onKeydown, true)
bound = true
}
function unbind(): void {
if (!bound) return
window.removeEventListener('keydown', onKeydown, true)
bound = false
}
onMounted(bind)
// keep-alive 内首次挂载后也会触发 activated,bind 幂等无需额外判断
onActivated(bind)
onDeactivated(unbind)
onBeforeUnmount(unbind)
}
@@ -14,7 +14,8 @@ import {
Sun,
User,
} from '@lucide/vue'
import { G3Message } from '@g3soft/ui'
import { G3Dropdown, G3Message } from '@g3soft/ui'
import type { DropdownItemData } from '@g3soft/ui'
import { usePermissionStore, useUserStore } from '@/stores'
import { usePreferencesStore } from '@/preferences'
import { useFullscreen } from '@/composables/useFullscreen'
@@ -75,6 +76,25 @@ function logout(): void {
router.replace('/login')
}
/**
* 用户卡片下拉菜单。
*
* 退出登录不再和用户卡片并排显示(两个入口挤在一起既占宽度又容易误点),
* 而是收进这里:点用户卡片才展开,与 vben 的「用户区即下拉」一致。
*
* 用 onClick 而不是监听 @click 事件:G3Dropdown 的项自带回调时优先执行,
* 不必再按 key 二次分发(与 SidebarMenuItem / LayoutHeaderMenu 同一套做法)。
*/
const userMenuItems = computed<DropdownItemData[]>(() => [
{
key: 'logout',
label: '退出登录',
icon: LogOut,
danger: true,
onClick: () => logout(),
},
])
/** 当前是否深色(auto 跟随系统时也能正确反映) */
const isDark = computed(() => prefStore.isDark)
@@ -208,20 +228,24 @@ function handleNotification(): void {
<Bell :size="18" />
</button>
<div class="layout-header__user">
<User :size="16" />
<span>{{ displayName }}</span>
<!-- 退出登录:用户模块落地前只有「回登录页」这一个动作 -->
<button
class="layout-header__logout"
type="button"
title="退出登录"
aria-label="退出登录"
@click="logout"
<!-- 用户区:整张卡片就是下拉触发器,退出登录收在菜单里
(不再单独显示退出按钮,避免两个入口并排挤在一起)。
unwrapped 默认开启,事件直接注入触发器单根 vnode,
所以下面这个 div 自己就是点击目标,不需要额外包一层。
触发器用 div + role=button 而不是 <button>:内部还有图标/文字多段结构。 -->
<G3Dropdown trigger="click" placement="bottom-end" :offset="6" :items="userMenuItems">
<div
class="layout-header__user"
role="button"
tabindex="0"
aria-haspopup="menu"
:title="displayName"
>
<LogOut :size="15" />
</button>
</div>
<User :size="16" />
<span class="layout-header__user-name">{{ displayName }}</span>
</div>
</G3Dropdown>
</div>
<PreferencesDrawer v-model:open="preferencesOpen" />
@@ -307,35 +331,39 @@ function handleNotification(): void {
color: var(--g3-color-foreground, #1f2329);
}
/* 用户卡片:整块可点(下拉触发器),因此要有按钮该有的悬停/聚焦反馈。
左右 padding 比图标按钮略大,让它看起来是「一张卡片」而不是一段文字。 */
&__user {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 6px;
height: 32px;
padding: 0 8px;
font-size: 13px;
color: var(--g3-color-foreground, #1f2329);
cursor: pointer;
border-radius: 6px;
outline: none;
transition: background-color 0.2s ease;
&:hover,
&:focus-visible {
background: var(--g3-color-accent, #f2f3f5);
}
/* 键盘聚焦也给出可见焦点环(鼠标点不给,避免多一圈突兀描边) */
&:focus-visible {
box-shadow: 0 0 0 2px var(--g3-color-primary, #1677ff);
}
}
/* 退出登录:比工具条按钮小一号,不抢视觉;色值走 inherit,半深色顶栏也不脱色 */
&__logout {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 6px;
opacity: 0.7;
&:hover {
background: var(--g3-color-accent, #f2f3f5);
opacity: 1;
}
/* 用户名过长时截断:顶栏宽度有限,不能让名字把右侧工具区挤变形 */
&__user-name {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
</style>
+22 -75
View File
@@ -1,89 +1,35 @@
import type { Component } from 'vue'
import type { MenuItem, SidebarMenuNode } from '@/types/menu'
import {
Blocks,
Building2,
CircleCheck,
Container,
Database,
FileText,
LayoutDashboard,
ListChecks,
Package,
Plane,
Settings,
Ship,
Table,
Users,
Wallet,
} from '@lucide/vue'
import { getIcon } from '@/utils/iconResolver'
/**
* 菜单图标注册表。
* 菜单图标解析。
*
* s_menu.b_icon 里存的是**图标名字符串**(如 `settings` / `database` / `circle-check`),
* 在此映射到 lucide 组件 —— 数据库只存名字,不存组件,前端换图标库不必洗数据。
* s_menu.b_icon 里存的是**图标名字符串**(如 `settings` / `circle-check`),
* 在此解析成组件 —— 数据库只存名字,不存组件,前端换图标库不必洗数据。
*
* ⚠️ 现实数据是 **kebab-case**(少数历史数据可能是 PascalCase),
* 因此查表时先按原名、再按归一化名(去连字符、首字母大写)各查一次。
* 只认一种写法会让另一批菜单**静默丢失图标**(getMenuIcon 返回 undefined,不报错)。
* 解析规则集中在 @/utils/iconResolver(与「菜单管理」的图标输入共用),支持:
* - 全部 lucide 图标名(kebab / camel / Pascal 各种写法都归一化)
* - `lucide:` 前缀(显式指定,匹配不到就不当 SVG 试)
* - 一段合法 SVG 标记(lucide 覆盖不到的行业专有图标)
*/
const iconMap: Record<string, Component> = {
// kebab-case:与 s_menu 实际存量数据一致
'circle-check': CircleCheck,
database: Database,
settings: Settings,
users: Users,
ship: Ship,
plane: Plane,
container: Container,
package: Package,
wallet: Wallet,
table: Table,
'list-checks': ListChecks,
'building-2': Building2,
'file-text': FileText,
'layout-dashboard': LayoutDashboard,
// 模块中心(超管固定入口,见 @/router 的 STATIC_MODULE_MENU):
// 只有一级分组项有图标,子项无图标(STATIC_MODULE_MENU.children 不写 icon)
blocks: Blocks,
// PascalCase:兼容早期常量菜单写下的名字
CircleCheck,
Database,
Settings,
Users,
Ship,
Plane,
Container,
Package,
Wallet,
Table,
ListChecks,
Building2,
FileText,
LayoutDashboard,
Blocks,
}
/** 把 `circle-check` 归一化成 `CircleCheck`;已是 PascalCase 的原样返回。 */
function toPascalCase(name: string): string {
return name
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* 按图标名取组件。
*
* @param name s_menu.b_icon 的原始值(kebab-case 或 PascalCase)
* @returns 图标组件;未登记的名字返回 undefined(不抛错,菜单照常渲染只是没图标)
* 解析走 @/utils/iconResolver 的 getIcon(与「菜单管理」的图标输入同源):
* 那里支持全部 lucide 图标 + `lucide:` 前缀 + SVG 源码,并做名称归一化。
*
* ⚠️ 这里曾经是一张 15 项的白名单。菜单图标改成自由输入后,白名单立刻成了 bug 源:
* 用户在菜单管理里填 `truck`(lucide 里确实有)能保存成功,侧栏却**静默没有图标**
* —— 正是本文件开头警告的那种失败。所以改为与输入侧共用同一解析器:
* 输入能预览出来的,侧栏就一定渲染得出来。
*
* @param name s_menu.b_icon 的原始值(lucide 名 / `lucide:` 前缀 / SVG 标记)
* @returns 图标组件;无法解析时返回 undefined(不抛错,菜单照常渲染只是没图标)
*/
export function getMenuIcon(name?: string): Component | undefined {
if (!name) return undefined
const key = String(name).trim()
return iconMap[key] ?? iconMap[toPascalCase(key)]
return getIcon(name) ?? undefined
}
/**
@@ -93,8 +39,9 @@ export function getMenuIcon(name?: string): Component | undefined {
* 实现,改一处必漏另一处。收敛到这里后,图标解析规则只存在一份。
*
* ⚠️ 幂等性不成立:传入的 icon 必须仍是**字符串**。已经是组件的树再走一遍
* getMenuIcon 会拿到 undefined(组件进不了 iconMap),表现为整棵树突然没图标。
* 所以调用方只能传 useVisibleMenuItems() 的原始输出,不要传本函数的返回值。
* getMenuIcon,组件会被当成图标名去解析(解析不出 → undefined),
* 表现为整棵树突然没图标。所以调用方只能传 useVisibleMenuItems() 的原始输出,
* 不要传本函数的返回值。
*/
export function mapMenuTree(list: MenuItem[]): SidebarMenuNode[] {
return list
+62 -1
View File
@@ -47,7 +47,7 @@ const HOME_PATH = '/dashboard'
*
* 新增页面时同样两步:① 本表加一条路由 ② s_menu 录一条菜单。
*/
const protectedRoutes: RouteRecordRaw[] = [
export const protectedRoutes: RouteRecordRaw[] = [
// ---- 首页(登录后落地页) ----
{
path: '/dashboard',
@@ -467,3 +467,64 @@ router.afterEach(() => {
export { HOME_PATH }
export default router
/* ================= 菜单管理的「路由地址」候选 ================= */
/** 拼接父路径与子路径(相对路径形态):'/module' + 'i18n-type' → '/module/i18n-type' */
function joinRoutePath(parentPath: string, childPath: string): string {
const parent = String(parentPath || '').replace(/\/+$/, '')
const child = String(childPath || '').replace(/^\/+/, '')
if (!parent) return child ? `/${child}` : ''
return child ? `${parent}/${child}` : parent
}
/**
* 收集可作为菜单入口的路由(对照 fms-vue 的 collectMenuRoutes)。
*
* 排除规则:
* - meta.adminOnly:超管专用页(模块中心)不作为业务菜单入口 ——
* 「菜单管理」自己的入口若落进 s_menu,误删后就没有修复入口了;
* - 含 children 的节点(布局容器 / 分组)不是最终页面,只向下递归;
* - 含动态段(:id)或通配(*)的路由无法作为固定菜单入口;
* - 403 / 500 兜底页。
*
* @param list 路由节点数组,默认取整张受保护路由表
* @param parentPath 父节点完整路径,用于拼接分组内子路由的相对路径
* @returns 选项(value 为完整 path,label 为「路径(标题)」)
*/
export function collectMenuRoutes(
list: RouteRecordRaw[] = protectedRoutes,
parentPath = '',
): Array<{ value: string; label: string }> {
const excluded = new Set(['/403', '/500'])
const result: Array<{ value: string; label: string }> = []
for (const route of list || []) {
if (route.meta?.adminOnly) continue
const rawPath = route.path || ''
// 绝对路径原样使用;相对路径(分组节点的 children)拼到父路径上
const path = rawPath.startsWith('/') ? rawPath : joinRoutePath(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.has(path)) continue
const title = route.meta?.title
result.push({
value: path,
label: typeof title === 'string' && title ? `${path}(${title})` : path,
})
}
return result
}
/**
* 菜单管理「路由地址」下拉选项:按路径排序。
*
* label 同时含路径与标题,配合 Select 的本地包含匹配,
* 输入路径片段(如 "/base")命中路径、输入标题关键词(如 "联系人")命中标题。
*/
export const menuRouteOptions = collectMenuRoutes().toSorted((a, b) =>
a.value.localeCompare(b.value, 'zh-Hans-CN'),
)
@@ -0,0 +1,226 @@
/**
* 数据变更比对(对照 fms-vue 的 src/utils/dataChanges.js)。
*
* 保存编排的通用底座:加载时留一份 `_org` 基线,保存时用这里的函数逐表 diff,
* 只提交真正变化的行。「加载基线 + 逐表 diff」这个口径下,各页面的保存流程长得一样,
* 差异只在「提交哪些表、表之间的主键怎么定」。
*
* 字符串业务键方案:行身份 = 主键列值组合(keyFields),不再是雪花 id。
* - 单列业务键:s_menu / s_module 传 ['b_id'];
* - 复合业务键:s_field 传 ['b_module_id','b_field']、s_i18n 传 ['b_key','b_locale']。
* 因此本模块**不需要** fms 那套 allocateTemporaryIds / remapForeignKeys
* (那是给「草稿行先用临时数字 id、落库后再回灌真实 id 并修正跨表外键」用的);
* 新增菜单就是直接用用户填的编码做主键,不存在 id 回灌。
*/
/** 深拷贝(结构化克隆的 JSON 版本):草稿与基线的隔离靠它 */
export function cloneData<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
/**
* 剔除派生字段:下划线前缀(`_*`)与视图前缀(`v_*`)。
*
* 前者是前端派生/虚拟字段(如 `_org` 基线、i18n 透视行的 `_id`),
* 后者是视图只读列;两者都不参与比较、也不提交后端。
* 数组逐元素递归,其余非对象原样返回。
*/
export function stripInternalKeys<T>(value: T): T {
if (Array.isArray(value)) return value.map(stripInternalKeys) as unknown as T
if (!value || typeof value !== 'object') return value
const stripped: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (key.startsWith('_') || key.startsWith('v_')) continue
stripped[key] = val
}
return stripped as T
}
/** 派生字段判定(`stripInternalKeys` 与字段级比较共用同一口径) */
function isIgnorableKey(key: string, ignoreSet: Set<string>): boolean {
return key.startsWith('_') || key.startsWith('v_') || ignoreSet.has(key)
}
/** 版本字段:不参与内容比较,提交时也不携带(后端维护) */
const VERSION_FIELDS = new Set(['b_version', 'mx_version'])
/** `diffRows` 的选项 */
export interface DiffRowsOptions {
/** 行身份列;默认 ['b_id']。单列业务键 / 复合业务键都通过它表达 */
keyFields?: string[]
}
/** `diffRows` 的结果:inserts / updates 是 current 里的**原对象引用**(不拷贝) */
export interface RowsDiff<T> {
inserts: T[]
updates: T[]
deletes: Array<Record<string, unknown>>
}
/**
* 行集合差异比对(子表;单条记录字段级比对用 `diffRow`)。
*
* 比较时忽略派生字段(`_*` / `v_*`),避免「打开即脏」。
* inserts / updates 返回 current 中的原对象引用而非副本:调用方常需要在提交前
* 就地补字段(如按菜单行派生权限点行),返回副本会让那些改动丢失。
* 派生字段的剥离统一延后到提交前调用 `sanitizeSaveReqs` 处理。
*/
export function diffRows<T extends Record<string, unknown>>(
current: T[],
previous: T[],
options: DiffRowsOptions = {},
): RowsDiff<T> {
const keyFields = options.keyFields?.length ? options.keyFields : ['b_id']
const keyOf = (item: T | undefined) =>
keyFields.map((field) => String(item?.[field] ?? '')).join('\u0001')
const previousByKey = new Map(previous.map((item) => [keyOf(item), item]))
const currentByKey = new Map(current.map((item) => [keyOf(item), item]))
return {
inserts: current.filter((item) => !previousByKey.has(keyOf(item))),
updates: current.filter((item) => {
const old = previousByKey.get(keyOf(item))
if (!old) return false
return (
JSON.stringify(stripInternalKeys(item)) !== JSON.stringify(stripInternalKeys(old))
)
}),
deletes: previous
.filter((item) => !currentByKey.has(keyOf(item)))
// delete 只带主键列:行已不存在,带其它列没有意义
.map((item) => Object.fromEntries(keyFields.map((field) => [field, item[field]]))),
}
}
/**
* 提交前剥离保存请求中的派生字段(`_*` / `v_*`),返回请求副本,不改原对象。
*
* 必须在「按草稿补全字段」之后调用:剥离产生副本,提前调用会让请求行与草稿行脱钩。
*/
export function sanitizeSaveReqs(saveReqs: Array<Record<string, unknown>>): Array<
Record<string, unknown>
> {
return (saveReqs || []).map((req) => {
const next = { ...req }
if (Array.isArray(next.inserts)) {
next.inserts = (next.inserts as unknown[]).map(stripInternalKeys)
}
if (Array.isArray(next.updates)) {
next.updates = (next.updates as unknown[]).map(stripInternalKeys)
}
return next
})
}
/** `diffRow` 的入参 */
export interface DiffRowInput {
current: Record<string, unknown> | null | undefined
original?: Record<string, unknown> | null
/** 主键列,默认 'b_id' */
primaryKey?: string
/** 额外忽略的列名 */
ignoreFields?: string[]
}
/** `diffRow` 的结果 */
export interface RowDiff {
hasChanges: boolean
inserts: Array<Record<string, unknown>>
updates: Array<Record<string, unknown>>
deletes: Array<Record<string, unknown>>
}
/** 值是否可当数字比较('' 不算,避免空串被当成 0) */
function isNumberValue(value: unknown): boolean {
if (typeof value === 'number') return !Number.isNaN(value)
if (typeof value === 'string') {
if (value.trim() === '') return false
return !Number.isNaN(Number(value))
}
return false
}
/** null / undefined / 空串 归一成 '',避免「空 vs 未填」被判成差异 */
function normalizeEmpty(value: unknown): unknown {
return value === undefined || value === null || value === '' ? '' : value
}
/** null / undefined / 空串 等价;两边都是数字时按数值比较(40 === '40.00') */
function isEqualValue(a: unknown, b: unknown): boolean {
const na = normalizeEmpty(a)
const nb = normalizeEmpty(b)
if (isNumberValue(na) && isNumberValue(nb)) {
return Math.abs(Number(na) - Number(nb)) < 0.000001
}
return JSON.stringify(na) === JSON.stringify(nb)
}
/**
* 单条记录字段级差异比对(主表;行集合请用 `diffRows`)。
*
* updates 只携带「变化的字段 + 主键」——后端按提供的列 SET,字段级更安全
* (整行 UPDATE 会把并发修改的其它列一起覆盖掉)。
* 无基线或基线无主键 → 视为新建(inserts 为过滤后的整行)。
*/
export function diffRow({
current,
original,
primaryKey = 'b_id',
ignoreFields = [],
}: DiffRowInput): RowDiff {
const ignoreSet = new Set(ignoreFields)
const pk = primaryKey
/** 版本字段与派生字段都不参与比较 */
const shouldSkip = (key: string) =>
isIgnorableKey(key, ignoreSet) || VERSION_FIELDS.has(key)
const result: RowDiff = { hasChanges: false, inserts: [], updates: [], deletes: [] }
const cur = current || {}
// 无基线或基线无主键 → 新建整行
const originalKey = original?.[pk]
if (!original || originalKey === undefined || originalKey === null || originalKey === '') {
const insert: Record<string, unknown> = {}
for (const [key, value] of Object.entries(cur)) {
if (shouldSkip(key)) continue
insert[key] = value
}
result.hasChanges = true
result.inserts.push(insert)
return result
}
// 修改:只携带变化字段 + 主键
const changed: Record<string, unknown> = {}
for (const key of Object.keys(cur)) {
if (key === pk || shouldSkip(key)) continue
if (!isEqualValue(cur[key], original[key])) changed[key] = cur[key]
}
if (Object.keys(changed).length) {
result.hasChanges = true
result.updates.push({ [pk]: cur[pk], ...changed })
}
return result
}
/** 组装一条保存请求(key_field 由主键列数组拼出,与后端契约一致) */
export function tableChange(
table: string,
changes: Partial<RowDiff>,
keyFields?: string[],
): Record<string, unknown> {
const keys = keyFields?.length ? keyFields : ['b_id']
return { table, key_field: keys.join(','), ...changes }
}
/** 数组长度(非数组记 0) */
function listLength(value: unknown): number {
return Array.isArray(value) ? value.length : 0
}
/** 该请求是否含实际变更(全空的请求不必发给后端) */
export function hasChanges(request: Record<string, unknown> | null | undefined): boolean {
if (!request) return false
return Boolean(
listLength(request.inserts) || listLength(request.updates) || listLength(request.deletes),
)
}
@@ -0,0 +1,165 @@
/**
* 图标值解析:字符串 → 可渲染的 Vue 组件(对照 fms-vue 的 src/utils/lucideIcons.js)。
*
* s_menu.b_icon 之类的列存的是**字符串**,不存组件 —— 数据库只记名字,
* 前端换图标库不必洗数据。本模块负责把那个字符串解析成组件:
* - `lucide:chevron-down` → 显式指定 lucide 图标(去前缀 + 名称归一化)
* - 其它非空字符串 → 先按 lucide 图标名匹配;匹配不到时,
* 仅当它是一段合法 SVG 标记才渲染(支持粘贴自定义图标)
* - 无效内容 → 返回 null,**不渲染也不抛错**
*
* 为什么支持 SVG 源码:lucide 是通用图标集,覆盖不到行业专有图标
* (船公司 logo、内部系统标识等),允许直接粘一段 `<svg>` 填补这个空缺。
*
* 为什么放在 ERP 的 utils 而不是组件库:命名归一化 / SVG 校验 / 缓存是纯函数,
* 但「接受 SVG 源码」这条是产品决策而非通用能力,先落在应用侧,
* 等第二个页面复用时再考虑上提(对照 fms 同名模块的定位)。
*/
import { defineComponent, h, type Component } from 'vue'
import * as LucideIcons from '@lucide/vue'
/** PascalCase 导出名 → 组件;首次调用时惰性建立 */
let iconMap: Record<string, Component> | null = null
function loadIcons(): Record<string, Component> {
if (iconMap) return iconMap
const map: Record<string, Component> = {}
for (const [key, component] of Object.entries(LucideIcons)) {
// lucide 同时导出 createLucideIcon 等小写开头的工具函数,只收组件(首字母大写)
if (key === 'default' || key === 'createLucideIcon') continue
if (key.charAt(0) !== key.charAt(0).toUpperCase()) continue
map[key] = component as Component
}
iconMap = map
return map
}
/**
* 各种命名风格归一化成 lucide 的 PascalCase 导出名:
* `chevron-down` / `chevron_down` / `chevronDown` / `CHEVRON DOWN` / `CHEVRONDOWN`
* 全部 → `ChevronDown`
*/
export function normalizeIconName(name: string): string {
const raw = String(name || '').trim()
if (!raw) return ''
return raw
.split(/[-_\s]+/)
.filter(Boolean)
.map(capitalizeSegment)
.join('')
}
/**
* 把一段(已按分隔符切开的)名字转成 PascalCase 里的一个词。
*
* 两边的坑都要躲开:
* - `CHEVRON` 这种全大写段:直接首字母大写会原样留下 `CHEVRON`
* (lucide 的导出名是 `Chevron`),所以全大写段先整体小写;
* - `chevronDown` 这种没有分隔符的 camelCase:整体转小写又会把词内的大写边界
* 抹掉(→ `Chevrondown`),所以含小写字母的段要保留原有大小写边界。
*/
function capitalizeSegment(segment: string): string {
// 全大写段(无分隔符写法里的 CHEVRON):整体小写后再首字母大写
if (segment === segment.toUpperCase()) {
const lower = segment.toLowerCase()
return lower.charAt(0).toUpperCase() + lower.slice(1)
}
// 含小写字母:保留词内的大小写边界(camelCase 的 D 不能被抹掉)
return segment.charAt(0).toUpperCase() + segment.slice(1)
}
/**
* 字符串是否为合法 SVG 标记:用 DOMParser 按 SVG 文档解析,
* 解析失败(存在 parsererror)或根元素不是 `<svg>` 都算无效。
* 无 DOMParser 的环境(测试 / SSR)回退到基础启发式判断。
*/
export function looksLikeSvg(content: string): boolean {
const trimmed = String(content || '').trim()
if (!trimmed) return false
if (typeof DOMParser === 'undefined') {
return /^<svg[\s>]/i.test(trimmed)
}
try {
const doc = new DOMParser().parseFromString(trimmed, 'image/svg+xml')
if (doc.querySelector('parsererror')) return false
const root = doc.documentElement
return !!root && root.tagName.toLowerCase() === 'svg'
} catch {
return false
}
}
/**
* SVG 源码 → 组件,按源码字符串缓存。
*
* 缓存是必要的:`getIcon` 在渲染函数里被调用,每次返回新组件会让 Vue
* 把整棵子树判定成「类型变了」而卸载重建 —— 表现为图标每帧闪烁。
*/
const svgComponentCache = new Map<string, Component>()
function getSvgComponent(svg: string): Component {
const cached = svgComponentCache.get(svg)
if (cached) return cached
const component = defineComponent({
name: 'RawSvgIcon',
render() {
return h('span', {
class: 'g3-raw-svg-icon',
style: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
},
innerHTML: svg,
})
},
})
svgComponentCache.set(svg, component)
return component
}
/**
* 解析图标值,返回可渲染的组件。
*
* @param value 图标字符串(`lucide:Name`、lucide 图标名、或 SVG 标记)
* @returns 图标组件;无效内容与空值返回 null(调用方据此不渲染,不抛错)
*/
export function getIcon(value?: string | null): Component | null {
if (!value || typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
const map = loadIcons()
// lucide 前缀:显式指定图标名,匹配不到就返回 null(不继续当 SVG 试)
if (trimmed.startsWith('lucide:')) {
return map[normalizeIconName(trimmed.slice('lucide:'.length))] ?? null
}
// 无前缀:先按 lucide 图标名匹配
const normalized = normalizeIconName(trimmed)
if (map[normalized]) return map[normalized]
// 既不是 lucide 名:仅当是合法 SVG 标记才渲染
return looksLikeSvg(trimmed) ? getSvgComponent(trimmed) : null
}
/** 图标值是否有效(用于表单校验:让拼错的名字当场报错,而不是静默不显示) */
export function isValidIconValue(value?: string | null): boolean {
const trimmed = String(value ?? '').trim()
// 留空是合法的(图标可选)
if (!trimmed) return true
return getIcon(trimmed) !== null
}
/**
* 全部 lucide 图标名(PascalCase,按字母序)。
*
* ⚠️ 目前没有调用方:菜单的图标输入是自由文本 + 预览(对照 fms 的设计),
* 不做全量下拉 —— 6000+ 项的列表既卡又无法浏览。保留它只为「将来要做一个
* 可搜索的图标面板」时不必再回来翻 lucide 的导出。
*/
export function getIconNames(): string[] {
return Object.keys(loadIcons()).toSorted()
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,221 @@
<script setup lang="ts">
/**
* 菜单基础信息面板(s_menu 主表单行)。
*
* 数据契约:v-model:menu 绑定父级持有的「编辑草稿」(选中菜单的 s_menu 行副本),
* 面板就地修改字段(与原型一致);保存编排在父级,本面板只管 UI 与校验。
*
* 布局:单列 + 竖排(label 在控件上方),一行一个字段。
* 不分组的理由见下方 G3Form 处;多语言键(b_i18n)不在界面上显示 ——
* 它由编码派生、用户改不了,占一个字段位只是噪音(派生逻辑照旧,见 onCodeInput)。
*
* 编码(b_id 即主键):仅未落库草稿可改,改动时同步派生多语言键 menu.xxx。
* 类型切换会清掉不适用字段(目录不用路由地址)。
*
* 校验规则(随输入实时提示;保存前由父级调 validate() 统一跑一遍):
* - 编码 / 名称 / 类型必填;
* - 页面与外链必须填路由地址(两者共用 b_route)。
*/
import { computed, ref } from 'vue'
import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui'
import type { FormRule } from '@g3soft/ui'
import IconPicker from '@/components/icon-picker/IconPicker.vue'
import { menuRouteOptions } from '@/router'
import { buildMenuI18nKey, MENU_TYPE_OPTIONS, type MenuNode } from './utils'
const menuModel = defineModel<MenuNode | null>('menu', { default: null })
const props = withDefaults(
defineProps<{
/** 编码只读:已保存菜单主键即编码,创建后不可修改 */
codeReadonly?: boolean
/** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */
loading?: boolean
}>(),
{ codeReadonly: false, loading: false },
)
const isPage = computed(() => menuModel.value?.b_menu_type === 'page')
const isExternal = computed(() => menuModel.value?.b_menu_type === 'external')
/** 页面与外链共用一个路由地址字段 */
const needsRoute = computed(() => isPage.value || isExternal.value)
/**
* 路由地址候选:由静态路由表(@/router)派生的页面路由。
* Select 的本地筛选即对 label / value 做包含匹配,等价于 like '%关键词%':
* 输入路径片段(如 "/base")命中路径、输入标题关键词(如 "联系人")命中标题。
*
* 库里已存但已不在路由表中的旧值(历史数据 / 路由已下线)补进首项,
* 否则它会变成下拉里选不到的孤立值。
*/
const routeOptions = computed(() => {
const current = String(menuModel.value?.b_route ?? '').trim()
if (!current) return menuRouteOptions
return menuRouteOptions.some((option) => option.value === current)
? menuRouteOptions
: [{ value: current, label: `${current}(已不在路由表中)` }, ...menuRouteOptions]
})
const formRules = computed<Record<string, FormRule[]>>(() => ({
b_id: [
{ required: true, message: '请填写菜单编码' },
{
validator: (value) => {
const code = String(value ?? '').trim()
if (!code) return '编码不能为空'
if (/^-\d+$/.test(code)) return '新建菜单编码不能以负数临时值保存'
if (code.length < 2 || code.length > 50) return '编码长度需为 2-50 个字符'
return /^[a-z][a-z0-9_]*$/.test(code)
? true
: '编码须以小写字母开头,且只能包含小写字母、数字和下划线'
},
},
],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route: needsRoute.value ? [{ required: true, message: '请填写路由地址' }] : [],
}))
const formRef = ref<InstanceType<typeof G3Form> | null>(null)
/** 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */
async function validate() {
return (await formRef.value?.validateAll()) ?? []
}
defineExpose({ validate })
/** 就地写草稿字段:空字符串统一收敛成 null(数据库可空列的约定) */
function setField(field: string, value: unknown) {
const draft = menuModel.value as Record<string, unknown> | null
if (!draft) return
draft[field] = value === '' ? null : value
}
/** 编码变化:始终以完整编码重新派生多语言键 */
function onCodeInput(value: string | number) {
setField('b_id', value)
setField('b_i18n', buildMenuI18nKey(String(value ?? '')))
}
/** 切换类型:目录不用路由地址,清掉草稿里的旧值 */
function onMenuTypeChange(value: unknown) {
const type = String(value ?? '')
setField('b_menu_type', type)
if (type === 'directory') setField('b_route', null)
}
</script>
<template>
<div v-if="menuModel" class="mm-basic">
<!-- 宽度 320px:单列下沿用模块管理那份 720px(三列宫格的宽度)会把输入框拉成长带子,
视线要从左扫到右才看得到行尾。320px 接近常规表单控件宽度,
菜单这几项(编码 / 名称 / 类型 / 路由 / 图标 / 排序 / 描述)都不需要更长。
需要更宽/更窄就调这一个值,别去动 CSS —— gap 与宽度都只能走 prop 进。 -->
<G3Form
ref="formRef"
:model="menuModel"
:rules="formRules"
layout="vertical"
:columns="1"
:gap="16"
max-width="320px"
>
<G3FormItem label="菜单编码" name="b_id" required>
<G3Input
:model-value="menuModel.b_id ?? ''"
:disabled="codeReadonly || loading"
placeholder="主键即编码,保存后不可修改"
@update:model-value="onCodeInput"
/>
</G3FormItem>
<G3FormItem label="菜单名称" name="b_name" required>
<G3Input
:model-value="menuModel.b_name ?? ''"
:disabled="loading"
@update:model-value="(value) => setField('b_name', value)"
/>
</G3FormItem>
<G3FormItem label="菜单类型" name="b_menu_type" required>
<G3Select
:model-value="menuModel.b_menu_type ?? ''"
:options="MENU_TYPE_OPTIONS"
:disabled="codeReadonly || loading"
@update:model-value="onMenuTypeChange"
/>
</G3FormItem>
<!-- page:路由地址从静态路由表选择(输入即按路径 / 标题模糊匹配) -->
<G3FormItem v-if="isPage" label="路由地址" name="b_route" required>
<G3Select
:model-value="menuModel.b_route ?? ''"
:options="routeOptions"
:disabled="loading"
placeholder="输入路径或页面标题搜索"
clearable
allow-free-input
@update:model-value="(value) => setField('b_route', value)"
/>
</G3FormItem>
<!-- external:外链地址(与路由地址共用 b_route 字段) -->
<G3FormItem v-else-if="isExternal" label="外链地址" name="b_route" required>
<G3Input
:model-value="menuModel.b_route ?? ''"
:disabled="loading"
placeholder="https://"
@update:model-value="(value: unknown) => setField('b_route', value)"
/>
</G3FormItem>
<!-- 目录不使用路由地址:给出说明而不是留一片空白 -->
<G3FormItem v-else label="路由地址" name="b_route">
<G3Input model-value="目录仅作导航分组,不需要路由地址" readonly />
</G3FormItem>
<G3FormItem label="图标" name="b_icon">
<IconPicker
:model-value="String(menuModel?.b_icon ?? '')"
:disabled="loading"
@update:model-value="(value: string) => setField('b_icon', value)"
/>
</G3FormItem>
<G3FormItem label="排序号" name="b_xh">
<G3Input
type="number"
:model-value="menuModel?.b_xh ?? ''"
:disabled="loading"
@update:model-value="(value: unknown) => setField('b_xh', Number(value) || 0)"
/>
</G3FormItem>
<G3FormItem label="描述" name="b_bz">
<G3Input
type="textarea"
:rows="2"
:model-value="menuModel?.b_bz ?? ''"
:disabled="loading"
@update:model-value="(value: unknown) => setField('b_bz', value)"
/>
</G3FormItem>
</G3Form>
</div>
</template>
<style lang="scss" scoped>
/* 面板内容自带内边距(骨架的 pane 只负责滚动,不设 padding) */
.mm-basic {
padding: 4px 0 16px;
}
/* 字段之间的距离由 G3Form 的 `:gap` 控制,**不能**用 CSS 的 gap 覆盖。
*
* 原因:`columns > 1` 时 Form 走 `display: grid`,间距是以**内联样式**下发的
* (Form.vue 的 formStyle:`style.rowGap = `${row ?? 12}px``)。
* 内联样式优先级高于任何样式表规则,所以写 `.g3-form { gap: 40px }` 不起作用
* —— 之前就是栽在这上面(值改了、CSS 也编译进去了,页面纹丝不动)。
* 取值链路是 prop → normalizeGap → 内联 rowGap,只能从 prop 进。
*
* `columns = 1` 时走另一条分支(flex + 内联 rowGap),同样是内联样式,结论不变。 */
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
/**
* 菜单树面板(menu-management 左侧)。
*
* 移植自 fms-vue 的 MenuTreePanel,改用 G3 业务树(BizTreePanel)——
* 与模块管理左侧树同构:搜索由 BizTreePanel 内部完成,本组件只上报意图。
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读:
* select / add / delete / drop / toggle-enabled。
* 展开键需由父级在新建菜单后追加祖先路径,故以 v-model:expanded-keys 暴露;
* 折叠态以 v-model:collapsed 透传(宽度动画由页面负责)。
*
* 行尾启用勾选:不用 G3Tree 内置的 `checkable`(那个勾选框在行首,是多选语义),
* 自己在 #title 插槽里画在行尾。代价与模块树相同:
* ① 点击不能冒泡(否则连带选中行 / 触发拖拽);
* ② 草稿节点不可勾(库里还没有这行);
* ③ 面板必须开 block-node,否则内容层不撑满、勾选框贴不到行尾。
*/
import { Plus, Trash2 } from '@lucide/vue'
import { G3Button, G3Checkbox, G3Dropdown } from '@g3soft/ui'
import type {
DropdownClickPayload,
TreeContextMenuItem,
TreeDropInfo,
TreeKey,
TreeNodeData,
} from '@g3soft/ui'
import BizTreePanel from '@/components/biz-tree/BizTreePanel.vue'
import { MENU_ADD_OPTIONS, type MenuNode } from './utils'
const props = withDefaults(
defineProps<{
/** 菜单树(s_menu 全量),父级持有并负责加载 / 保存 */
treeData: MenuNode[]
/** 树数据加载中 */
loading?: boolean
/** 当前选中菜单 id */
selectedId?: string | null
/** 展开的树节点 key(v-model:新建菜单后由父级追加祖先路径) */
expandedKeys?: TreeKey[]
/** 折叠态(v-model:collapsed):透传给 BizTreePanel */
collapsed?: boolean
/** 未落库(新建未保存)的菜单 id 集合;父级唯一状态源,本组件只读 */
unsavedIds?: Set<string>
}>(),
{
loading: false,
selectedId: null,
expandedKeys: () => [],
unsavedIds: () => new Set<string>(),
},
)
const emit = defineEmits<{
'update:expandedKeys': [keys: TreeKey[]]
'update:collapsed': [collapsed: boolean]
select: [id: string]
add: [payload: { parentId: string | null; menuType: string }]
delete: [id: string | null]
drop: [payload: TreeDropInfo]
/** 行尾勾选:切换某菜单的启用态(父级负责落库并回写 b_canuse) */
toggleEnabled: [payload: { id: string; enabled: boolean }]
}>()
function onExpandedKeysChange(keys: TreeKey[]) {
emit('update:expandedKeys', keys)
}
function onCollapsedChange(collapsed: boolean) {
emit('update:collapsed', collapsed)
}
/** 选中:仅上报目标 id,是否需要未保存确认由父级决定 */
function onSelect(keys: TreeKey[]) {
if (!keys.length) return
const nextId = String(keys[0])
if (nextId === String(props.selectedId)) return
emit('select', nextId)
}
/** 节点是否可勾选:未落库草稿在库里没有行,改不了它的 b_canuse */
function isNodeCheckable(node: TreeNodeData): boolean {
return !props.unsavedIds.has(String((node as MenuNode).b_id))
}
function onToggleEnabled(node: TreeNodeData, enabled: boolean) {
if (!isNodeCheckable(node)) return
emit('toggleEnabled', { id: String((node as MenuNode).b_id), enabled })
}
/**
* 右键菜单项数据源。
* 未保存(草稿)节点不能作为父级(其 id 尚未落库,子菜单会凭空挂在临时 id 下)。
*/
function buildContextMenu(node: TreeNodeData | null): TreeContextMenuItem[] {
const isUnsaved = node != null && props.unsavedIds.has(String((node as MenuNode).b_id))
const items: TreeContextMenuItem[] = MENU_ADD_OPTIONS.map((option) => ({
...option,
disabled: isUnsaved,
}))
if (node) {
items.push({ key: '__divider__', divider: true })
items.push({ key: 'delete', label: '删除菜单', icon: Trash2, danger: true })
}
return items
}
function onContextMenuClick({
item,
node,
}: {
item: TreeContextMenuItem
node: TreeNodeData | null
}) {
const parentId = node ? String((node as MenuNode).b_id) : null
if (item.key === 'delete') emit('delete', parentId)
else emit('add', { parentId, menuType: String(item.key) })
}
/** 工具栏新建:挂到当前选中节点下,无选中则顶级 */
function onToolbarAdd({ key }: DropdownClickPayload) {
emit('add', {
parentId: props.selectedId ? String(props.selectedId) : null,
menuType: String(key),
})
}
function onDrop(info: TreeDropInfo) {
emit('drop', info)
}
</script>
<template>
<BizTreePanel
title="菜单"
placeholder="搜索菜单"
draggable
block-node
:tree-data="treeData"
:loading="loading"
:expanded-keys="expandedKeys"
:collapsed="collapsed"
@update:expanded-keys="onExpandedKeysChange"
@update:collapsed="onCollapsedChange"
:selected-keys="selectedId ? [selectedId] : []"
:context-menu="buildContextMenu"
@select="onSelect"
@context-menu-click="onContextMenuClick"
@drop="onDrop"
>
<template #add>
<G3Dropdown :items="MENU_ADD_OPTIONS" @click="onToolbarAdd">
<G3Button type="ghost" size="small" title="新增菜单" aria-label="新增菜单">
<Plus :size="16" />
</G3Button>
</G3Dropdown>
</template>
<template #title="{ node, segments }">
<span class="mm-tree-node">
<span
class="mm-tree-node__name"
:class="{ 'is-disabled': Number((node as MenuNode).b_canuse) !== 1 }"
>
<!-- segments 来自面板:命中的关键字片段带 .biz-tree-node-name__hit 高亮 -->
<template v-for="(segment, index) in segments" :key="index">
<span v-if="segment.hit" class="biz-tree-node-name__hit">{{ segment.text }}</span>
<template v-else>{{ segment.text }}</template>
</template>
</span>
<span
v-if="unsavedIds.has(String((node as MenuNode).b_id))"
class="mm-tree-node__unsaved"
title="未保存"
aria-label="未保存"
/>
<!-- 行尾启用勾选:快捷切换本菜单的启用态。
@click.stop 必须留:行上挂了 click(选中)与 mousedown(拖拽武装),
不拦就会「勾一下顺带把这一行也选中 / 拖起来」。 -->
<span class="mm-tree-node__enable" @click.stop>
<G3Checkbox
:model-value="Number((node as MenuNode).b_canuse) === 1"
:disabled="!isNodeCheckable(node)"
:title="
isNodeCheckable(node)
? Number((node as MenuNode).b_canuse) === 1
? '点击停用该菜单'
: '点击启用该菜单'
: '未保存的菜单不能启用/停用'
"
@update:model-value="(value) => onToggleEnabled(node, value)"
/>
</span>
</span>
</template>
</BizTreePanel>
</template>
<style lang="scss" scoped>
/* 面板骨架(白卡 / 标题栏 / 搜索 / 分割线 / 滚动区)见 BizTreePanel.vue */
/* 树节点单行:名称(停用变灰)撑满剩余空间。
.mm-tree-node 是节点行内容(flex 容器)的子元素,需 flex: 1 占满整行宽度;
面板上的 block-node 是这一条能生效的前提(否则内容层只有文字宽)。 */
.mm-tree-node {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
padding: 2px 0;
line-height: 1.3;
}
.mm-tree-node__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-tree-node__name.is-disabled {
color: var(--g3-color-text-tertiary, #86909c);
}
/* 未保存(新建未落库)标记:库里还没有这行,切换节点前必须保存,否则会随草稿丢弃 */
.mm-tree-node__unsaved {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--g3-color-warning, #faad14);
}
/* 行尾启用勾选:不参与压缩,始终贴在行尾 */
.mm-tree-node__enable {
display: inline-flex;
align-items: center;
flex: none;
margin-left: 2px;
}
/* 无文案时 G3Checkbox 是 inline-flex + gap,这里把多余间距压掉 */
.mm-tree-node__enable :deep(.g3-checkbox) {
gap: 0;
}
</style>
@@ -0,0 +1,179 @@
<script setup lang="ts">
/**
* 绑定模块面板(menu-management 右侧,与「菜单配置」并排)。
*
* 为什么是常驻面板而不是弹层:绑定是菜单属性的一部分,配完要能一眼看到「这个菜单挂了哪些模块」;
* 弹层则每次都要「打开 → 勾 → 确定」,确认前看不到已绑关系,也容易漏点确定白配一次。
*
* 交互:按模块树勾选当前页面使用的模块。勾分类节点(module)会自动级联勾选其下全部模块
* —— 由 G3Tree 的 checkable 非严格模式提供(点父级整棵子树开 / 关,半选态自动计算)。
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与绑定,
* 回写模型前统一过滤;树上的勾选草稿(含分类节点)只存在于本组件。
*
* 数据契约:v-model 双向绑定已绑模块编码数组。差异比对与落库(s_menu_module)
* 由父级负责 —— 本面板只维护草稿,随菜单的「保存」一起提交。
*
* 尚未移植:fms-vue 该面板右侧还有「权限点表 + 权限库」两块(就地增删 s_power、
* 从历史动作库导入)。本文件先只做模块绑定,那两块随权限相关步骤补。
*/
import { computed, ref, watch } from 'vue'
import { G3Tag } from '@g3soft/ui'
import type { TreeKey } from '@g3soft/ui'
import BizTree from '@/components/biz-tree/BizTree.vue'
import type { TreeRow } from '@/utils/tree'
import {
filterBindableModules,
isBindableModule,
MENU_BIND_MENU_TYPE,
moduleBindSignature,
} from './utils'
const props = withDefaults(
defineProps<{
/** 模块树(module 分类 + data / virtual 数据模块),父级加载一次后传入 */
treeData?: TreeRow[]
/** 模块编码 → b_module_type;缺少映射的键按可绑定处理,避免误删历史绑定 */
moduleTypes?: Record<string, string>
/** 菜单类型(directory / page / external):只有页面菜单绑定模块 */
menuType?: string
/** 当前菜单标识:切换菜单时清掉组件内的勾选草稿 */
menuId?: string
/** 模块清单加载中 */
loading?: boolean
}>(),
{
treeData: () => [],
// 默认值必须是内联写法:defineProps 会被提升到 setup 之外,
// 引用模块级常量(即使是 const)编译期直接报错(checkInvalidScopeReference)。
// 这里的括号包裹空对象字面量:写成返回空表达式的箭头函数会让 props 变 undefined。
moduleTypes: () => ({}),
menuType: '',
menuId: '',
loading: false,
},
)
/** 已绑模块编码(s_menu_module 草稿,父级持有并随菜单保存一并提交) */
const modules = defineModel<string[]>({ default: () => [] })
/** 仅页面菜单绑定模块:目录 / 外链换一句说明,不渲染可勾的树 */
const canBind = computed(() => props.menuType === MENU_BIND_MENU_TYPE)
/** 不可绑定的原因(目录与外链分别说明,不共用一句含糊的「不可用」) */
const stateText = computed(() =>
props.menuType === 'directory' ? '目录不绑定模块' : '外链不绑定模块',
)
/**
* 树上的勾选草稿:含被勾中的分类节点(非严格模式下点父级会把整棵子树的键回传)。
* 外部值变化(切换菜单 / 重载 / 保存后清空)时按签名重置;本组件自身的回写签名一致,
* 因此不会重置 —— 刚点亮的分类节点不会因为回写模型而闪掉。
*/
const draftChecked = ref<string[]>([])
watch(
() => modules.value,
(value) => {
const next = (value || []).map(String)
if (moduleBindSignature(next) === moduleBindSignature(draftChecked.value)) return
draftChecked.value = next
},
{ immediate: true },
)
/** 勾选变化:树上记全量(含分类节点),回写模型只留可绑定的 data / virtual */
function onCheckChange(keys: TreeKey[]): void {
draftChecked.value = keys.map(String)
modules.value = filterBindableModules(keys, props.moduleTypes)
}
/** 已绑数量(只算可绑定的,与落库口径一致) */
const boundCount = computed(
() =>
(modules.value || []).filter((key) => isBindableModule(props.moduleTypes[String(key)]))
.length,
)
/**
* 切换菜单:上一个菜单的勾选草稿不该留下。
* 未保存改动由父级在切换前用「有未保存改动」提示拦下,确认放弃后才走到这里。
*/
watch(
() => props.menuId,
() => {
draftChecked.value = []
},
)
</script>
<template>
<div class="mm-bind">
<div class="mm-bind__head">
<span class="mm-bind__title">绑定模块</span>
<!-- 数字是已绑数量;不写「已绑 3 个模块」这类解释性文案 -->
<G3Tag v-if="canBind && boundCount" variant="secondary">{{ boundCount }}</G3Tag>
</div>
<div class="mm-bind__body">
<div v-if="!canBind" class="mm-bind__state">{{ stateText }}</div>
<div v-else class="mm-bind__tree">
<!-- 不设 default-expand-all:整棵树默认收起,由用户按需逐层点开 -->
<BizTree
v-model:checked-keys="draftChecked"
:tree-data="treeData"
:loading="loading"
checkable
@update:checked-keys="onCheckChange"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 白卡 + 卡头常驻 + 正文自己滚动,与「菜单配置」卡同构 */
.mm-bind {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--g3-color-bg-container, #fff);
border-radius: var(--g3-radius-base, 0.5rem);
}
.mm-bind__head {
display: flex;
align-items: center;
gap: 6px;
flex: none;
padding: 10px 14px;
border-bottom: 1px solid var(--g3-color-border, #e5e6eb);
}
.mm-bind__title {
font-size: 13px;
font-weight: 600;
color: var(--g3-color-text, #1f2329);
}
.mm-bind__body {
flex: 1;
min-height: 0;
}
/* 模块树:撑满面板高度,长树在自己区域内滚动而不是把卡片顶高 */
.mm-bind__tree {
height: 100%;
overflow: auto;
padding: 6px 8px;
}
/* 目录 / 外链没有模块绑定:内容区中间给一句说明,空着一片反而像没加载出来 */
.mm-bind__state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--g3-color-text-tertiary, #86909c);
font-size: 13px;
}
</style>
@@ -0,0 +1,229 @@
/**
* 菜单管理公共工具(移植自 fms-vue 的 menu-management/utils.js)。
*
* 只放无 Vue 状态依赖的纯逻辑:菜单类型选项、行提取、多语言键、入口权限点派生。
* 树本身的通用查找 / 过滤 / 插入 / 移除 / 位置派生见 @/utils/tree,不在此重复。
*/
import { ExternalLink, FileText, Folder } from '@lucide/vue'
import type { Component } from 'vue'
import { buildMenuPowerCode, buildPowerDeleteDependents } from '@/utils/powerCodes'
import type { TreeRow } from '@/utils/tree'
/** 菜单类型(s_menu.b_menu_type):directory 目录 / page 页面 / external 外链 */
export const MENU_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '外链', value: 'external' },
]
/** 菜单类型 → 树节点图标(预载到 node.icon,G3Tree 自动渲染) */
export const MENU_TYPE_ICON_MAP: Record<string, Component> = {
directory: Folder,
page: FileText,
external: ExternalLink,
}
/** 菜单树里能承载子节点的类型:页面 / 外链是末端节点 */
export const MENU_NON_LEAF_TYPES = ['directory']
/** 工具栏 / 右键「新增」菜单项(key 即 b_menu_type) */
export const MENU_ADD_OPTIONS: Array<{ key: string; label: string; icon: Component }> = [
{ key: 'directory', label: '新增目录', icon: Folder },
{ key: 'page', label: '新增页面', icon: FileText },
{ key: 'external', label: '新增外链', icon: ExternalLink },
]
/**
* 菜单树节点:s_menu 行业务列 + 树运行时挂上的 children / icon 等。
* 与 @/utils/tree 的 TreeRow 兼容(可直接交给建树 / 拖拽等纯函数)。
*/
export type MenuNode = TreeRow & {
b_id: string
b_parent_id: string | null
b_name: string
b_i18n: string | null
b_menu_type: string
b_route: string | null
b_icon: string | null
b_xh: number
b_canuse: number
/** 备注 / 描述(s_menu.b_bz)。索引签名兜底,但显式声明便于面板取用 */
b_bz?: string | null
children?: MenuNode[]
}
/** s_menu 主表列(树节点 → 主表草稿行时按此提取,剔除 children/isLeaf/icon 等树注入字段) */
export const MENU_ROW_FIELDS = [
'b_id',
'b_parent_id',
'b_depth',
'b_path',
'b_name',
'b_i18n',
'b_menu_type',
'b_route',
'b_icon',
'b_xh',
'b_canuse',
] as const
/**
* 从树节点提取纯菜单行(剔除 children/isLeaf/icon 等树注入字段)。
* 未落库节点切回时,用它从树节点重建主表草稿。
*/
export function menuRowOf(node: Partial<MenuNode>): Record<string, unknown> {
const row: Record<string, unknown> = {}
// 字符串业务键:b_id 即菜单编码,不再有独立 b_code
for (const field of MENU_ROW_FIELDS) row[field] = node[field] ?? null
row.b_parent_id = node.b_parent_id ? String(node.b_parent_id) : null
// b_path 非空且无默认值:缺省时退化为空串占位,不能为 null
row.b_depth = Number(node.b_depth ?? 0)
row.b_path = node.b_path == null ? '' : String(node.b_path)
return row
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
/**
* 菜单多语言资源键:menu.{菜单编码}。
* 加载、保存、删除都以此键定位 s_i18n 行(s_i18n 无菜单 / 模块归属列)。
*
* @returns 资源键;菜单编码为空时返回 null
*/
export function buildMenuI18nKey(menuId: string): string | null {
const normalized = String(menuId || '').trim()
return normalized ? `menu.${normalized}` : null
}
/* ================= 菜单入口权限点(s_power,b_object_type = 'menu') ================= */
/**
* 需要入口权限点的菜单类型:目录只是导航分组(侧栏按子菜单裁剪),
* 页面与外链才是「能否进入」的入口,各对应一行 menu.{菜单编码}(b_action = 'access')。
*/
export const MENU_POWER_MENU_TYPES = ['page', 'external']
/** 参与同步的 s_power 列:b_bz 是备注,不随菜单变化,不参与 diff */
const MENU_POWER_FIELDS = [
'b_id',
'b_name',
'b_i18n',
'b_type',
'b_object_type',
'b_object_id',
'b_action',
'b_canuse',
'b_xh',
] as const
/**
* 归一化 s_power 行为入口权限点的可比字段(剔除 b_bz 等不参与同步的列,
* 数字列统一为 number,避免 '10' 与 10 产生假差异)。
*/
export function normalizeMenuPowerRow(
row: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!row) return null
const picked: Record<string, unknown> = {}
for (const field of MENU_POWER_FIELDS) picked[field] = row[field] ?? null
picked.b_canuse = Number(picked.b_canuse ?? 0)
picked.b_xh = Number(picked.b_xh ?? 0)
return picked
}
/**
* 由菜单行派生入口权限点行:名称、多语言键、启用状态、顺序全部跟随菜单。
*
* 保存菜单时按它与加载基线 diff,权限点因此不需要单独的编辑界面 ——
* 改菜单名 / 停用菜单,入口权限点自动跟着变。
*
* @returns s_power 行;目录等不需要入口权限的菜单返回 null
*/
export function buildMenuPowerRow(
menuRow: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
const menuId = String(menuRow?.b_id || '').trim()
if (!menuId) return null
if (!MENU_POWER_MENU_TYPES.includes(String(menuRow?.b_menu_type || ''))) return null
return {
b_id: buildMenuPowerCode(menuId),
b_name: String(menuRow?.b_name ?? ''),
b_i18n: buildMenuI18nKey(menuId),
b_type: 'menu',
b_object_type: 'menu',
b_object_id: menuId,
b_action: 'access',
b_canuse: Number(menuRow?.b_canuse ?? 1) === 0 ? 0 : 1,
b_xh: Number(menuRow?.b_xh ?? 0) || 0,
}
}
/** 删入口权限点时的依赖声明(授权行随权限点删除),与删除入口共用一份 */
export { buildMenuPowerCode, buildPowerDeleteDependents }
/* ================= 模块绑定(s_menu_module) ================= */
/** 可绑定模块的类型:module 只是模块树上的分类,不参与绑定 */
export const BINDABLE_MODULE_TYPES = ['data', 'virtual']
/** 只有页面菜单绑定模块 */
export const MENU_BIND_MENU_TYPE = 'page'
/**
* 该模块编码是否可绑定。
* 类型未知(模块清单里没有该键)时按**可绑定**处理:宁可保留历史绑定行,
* 也不因为清单没加载全就把用户已配好的关系当成脏数据删掉。
*/
export function isBindableModule(moduleType: string | null | undefined): boolean {
if (moduleType == null || moduleType === '') return true
return BINDABLE_MODULE_TYPES.includes(String(moduleType))
}
/** 过滤出可绑定的模块编码(剔除分类节点,保持原顺序) */
export function filterBindableModules(
keys: readonly unknown[],
moduleTypes: Record<string, string>,
): string[] {
return (keys || []).map(String).filter((key) => isBindableModule(moduleTypes[key]))
}
/**
* 顺序无关签名:比较两份编码集合是否一致。
* 树回写(勾父级会带回整棵子树的键,且顺序可能不同)不该被判定成「外部值变了」,
* 否则刚点亮的分类节点会因为回写而闪掉。
*/
export function moduleBindSignature(keys: readonly unknown[]): string {
return [...(keys || [])].map(String).toSorted().join(',')
}
/**
* 已绑模块编码 → s_menu_module 行。
* b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。
*/
export function buildMenuModuleRows(
menuId: string,
moduleIds: readonly unknown[],
): Array<Record<string, unknown>> {
const id = String(menuId || '').trim()
if (!id) return []
return (moduleIds || []).map(String).map((moduleId, index) => ({
b_menu_id: id,
b_module_id: moduleId,
b_xh: (index + 1) * 10,
b_canuse: 1,
}))
}
/**
* 按菜单类型收敛绑定草稿:非页面菜单不绑定模块,改为非页面类型时清空。
*
* 由父级在**菜单类型变化时**调用(面板在非页面类型下不渲染树,草稿留着也看不见,
* 一旦切回页面类型就会「凭空冒出」一批用户以为已经丢掉的绑定)。
* 保存前还会再按最终类型收敛一次,两道防线保证非页面菜单不会写进关系行。
*/
export function normalizeMenuModulesByType(
menuType: string | null | undefined,
moduleIds: readonly unknown[],
): string[] {
return String(menuType || '') === MENU_BIND_MENU_TYPE ? (moduleIds || []).map(String) : []
}
@@ -7,15 +7,21 @@
* b_currentperiod / b_currentvalue 是运行时流水状态(服务端维护),预览读取它们但从不回写。
*
* 惰性建行:模块从未配置自动编码时,草稿保持空数组——打开面板即建行会让「无变更」
* 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(拨开关或改参数)才落行。
* 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(改任一参数)才落行。
*
* 布局(5 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素):
* ┌ 第一行:启用 / 业务编号字段 / 重置周期
* ├ 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值
* 布局(4 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素):
* ┌ 第一行:业务编号字段 / 重置周期 / 前缀 / 日期
* ├ 第二行:分隔符 / 位数 / 起始值
* └ 第三行:编号预览(跨整行)
*
* 「启用」开关不在这里:它是本面板的总开关,与它管辖的参数同级铺在网格里
* 会被读成「其中一个字段」,且一关就整片置灰。开关上提到页面级面板头
* (index.vue,与「同步字段」「预览查询入口」同一操作区),本面板通过
* defineExpose 暴露 enabled / toggleEnabled 供父级驱动,并把 enabled 传回来
* 决定参数是否可编辑。
*/
import { computed, reactive, ref } from 'vue'
import { G3Form, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui'
import { computed, reactive, ref, watch } from 'vue'
import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui'
import type { FormRule } from '@g3soft/ui'
import type { ModuleAutoCodeRow, ModuleFieldRow } from './utils'
@@ -27,10 +33,20 @@ const props = withDefaults(
fields?: ModuleFieldRow[]
/** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */
loading?: boolean
/**
* 启用态(由父级面板头的开关驱动)。
* 面板内不再自持开关,参数区的可编辑性读它;未传时退化为「按草稿行判断」。
*/
enabled?: boolean
}>(),
{ fields: () => [], loading: false },
{ fields: () => [], loading: false, enabled: undefined },
)
const emit = defineEmits<{
/** 草稿行被本面板改动(含开关翻转)后回报启用态,父级据此点亮面板头开关 */
'update:enabled': [value: boolean]
}>()
/** 自动编码规则草稿;空数组表示本模块未配置自动编码 */
const autoCodeModel = defineModel<ModuleAutoCodeRow[]>('autocode', { default: () => [] })
@@ -97,11 +113,17 @@ const localRow = computed<ModuleAutoCodeRow | null>(() =>
*/
const emptyRow = computed(() => createDraft(props.moduleId, props.fields, false))
/** 表单绑定行:优先真实行,未配置时回退占位行,保证「启用」开关始终可见可拨 */
/** 表单绑定行:优先真实行,未配置时回退占位行(表格始终有值可渲染) */
const formRow = computed<ModuleAutoCodeRow>(() => localRow.value ?? emptyRow.value)
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
/**
* 参数区实际使用的启用态:以父级传进来的 enabled 为准(开关在面板头),
* 未传时退化成本地判断——单独挂这个面板(如单测)时行为不变。
*/
const isEnabled = computed(() => props.enabled ?? autoCodeEnabled.value)
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed<Record<string, FormRule[]>>(() => {
const field = (props.fields || []).find(
@@ -133,7 +155,15 @@ const formRules = computed<Record<string, FormRule[]>>(() => {
message: '起始值必须是大于或等于 0 的整数',
},
],
b_resettype: [{ required: true, message: '请选择重置周期' }],
b_resettype: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
return String(value ?? '') ? true : '请选择重置周期'
},
message: '请选择重置周期',
},
],
b_seqwidth: [
{
validator: (value) => {
@@ -152,12 +182,18 @@ const formRules = computed<Record<string, FormRule[]>>(() => {
const formRef = ref<InstanceType<typeof G3Form> | null>(null)
/** 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */
/**
* 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用)
*/
async function validate() {
// 未启用不校验:未配置(占位行)或已停用的规则都不该拦保存。
// 用 isEnabled(父级口径)而不是本地 autoCodeEnabled:开关在面板头,
// 父级的 enabled 是权威值。
if (!isEnabled.value) return []
return (await formRef.value?.validateAll()) ?? []
}
defineExpose({ validate })
/* ---------- 启用态:开关在页面级面板头,这里只提供读写口 ---------- */
/** 业务编号字段下拉候选 */
const fieldOptions = computed(() =>
@@ -180,10 +216,64 @@ function ensureRow(): ModuleAutoCodeRow {
return rows[0]
}
/**
* 本次「拨开」是否新建了占位行。
*
* 判断「关掉时该不该丢弃这行」不能回读 autoCodeModel —— defineModel 写入后
* prop 要下一轮才回传,紧接着读仍是旧数组(空),会把「刚建的行」误判成
* 「不存在的行」;连拨两次(开→关)时尤其明显。记一次意图即可,不依赖回读。
*/
let rowCreatedByToggle = false
/**
* 拨开关:置 b_canuse(首次拨会建行)。
* 关掉时同时丢弃「刚建出来又被关掉」的占位行——模块从未配置过自动编码、
* 用户打开又关掉,草稿里会留下一行 b_canuse = 0 的记录,保存时被 diff 成 insert,
* 「打开过就算配置过」会凭空多出一条规则行。
* 关掉既有规则(配过又停用)则保留:那是既有规则的停用,要落库。
*/
function toggleEnabled(checked: boolean) {
if (!checked && rowCreatedByToggle) {
autoCodeModel.value = []
rowCreatedByToggle = false
return
}
const existed = autoCodeModel.value.length > 0
ensureRow().b_canuse = checked ? 1 : 0
if (!existed) rowCreatedByToggle = true
}
// 启用态回报给父级(驱动面板头开关)。
// immediate:草稿在挂载时就带行(打开已配置的模块)时也要报一次,
// 否则父级开关会停在默认的 false,与面板内容不一致。
// 这里回报的是「行内的真实 b_canuse」,拨开关导致的行变化随后会纠偏一次——
// 两条路径收敛到同一真相,父级无需自己推算。
watch(
localRow,
(row) => {
emit('update:enabled', Number(row?.b_canuse) === 1)
},
{ immediate: true },
)
// 草稿被整体换掉(切模块 / 重载)时清掉建行意图:
// 上一模块留下的 true 会让新模块的第一次「关」误丢行
watch(
() => props.moduleId,
() => {
rowCreatedByToggle = false
},
)
defineExpose({
/** 保存前校验(未启用直接通过) */
validate,
/** 当前启用态(面板头开关读它) */
enabled: isEnabled,
/** 拨开关:置 b_canuse,首次拨会惰性建行 */
toggleEnabled,
})
/** 就地写草稿行字段:ensureRow 保证行已存在(用户首次操作时才落行) */
function setRowValue(field: string, value: unknown) {
ensureRow()[field] = value
@@ -244,39 +334,30 @@ const preview = computed(() => {
:model="formRow"
:rules="formRules"
layout="vertical"
:columns="5"
:columns="4"
max-width="720px"
>
<!-- 第一行:启用 / 业务编号字段 / 重置周期 -->
<G3FormItem label="启用" name="b_canuse">
<G3Switch
:model-value="autoCodeEnabled"
:disabled="loading"
@update:model-value="toggleEnabled"
/>
</G3FormItem>
<G3FormItem label="业务编号字段" name="b_field" :span="2">
<!-- 第一行:业务编号字段 / 重置周期 / 前缀 / 日期 -->
<G3FormItem label="业务编号字段" name="b_field">
<G3Select
:model-value="String(formRow.b_field || '')"
:options="fieldOptions"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_field', value)"
/>
</G3FormItem>
<G3FormItem label="重置周期" name="b_resettype" :span="2">
<G3FormItem label="重置周期" name="b_resettype">
<G3Select
:model-value="formRow.b_resettype"
:options="RESET_OPTIONS"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_resettype', value)"
/>
</G3FormItem>
<!-- 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 -->
<G3FormItem label="前缀" name="b_prefix">
<G3Input
:model-value="formRow.b_prefix ?? ''"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_prefix', value)"
/>
</G3FormItem>
@@ -284,15 +365,17 @@ const preview = computed(() => {
<G3Select
:model-value="formRow.b_dateformat ?? ''"
:options="DATE_FORMAT_OPTIONS"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
allow-free-input
@update:model-value="(value) => setRowValue('b_dateformat', value)"
/>
</G3FormItem>
<!-- 第二行:分隔符 / 位数 / 起始值 -->
<G3FormItem label="分隔符" name="b_separator">
<G3Input
:model-value="formRow.b_separator ?? ''"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_separator', value)"
/>
</G3FormItem>
@@ -300,7 +383,7 @@ const preview = computed(() => {
<G3Input
:model-value="formRow.b_seqwidth"
type="number"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_seqwidth', value)"
/>
</G3FormItem>
@@ -308,7 +391,7 @@ const preview = computed(() => {
<G3Input
:model-value="formRow.b_startvalue"
type="number"
:disabled="loading || !autoCodeEnabled"
:disabled="loading || !isEnabled"
@update:model-value="(value) => setRowValue('b_startvalue', value)"
/>
</G3FormItem>
@@ -317,9 +400,9 @@ const preview = computed(() => {
<G3FormItem
label="编号预览"
name="b_preview"
:span="5"
:span="4"
class="mm-auto-code__preview-item"
:class="{ 'is-disabled': !autoCodeEnabled }"
:class="{ 'is-disabled': !isEnabled }"
>
<G3Input :model-value="preview" readonly :disabled="loading" />
</G3FormItem>
@@ -7,12 +7,21 @@
* 编码(b_id 即主键):仅未落库草稿可改,改动时同步派生多语言键 module.xxx;
* 类型切换会清掉不适用字段(module 无数据来源;virtual 无保存表)。
*
* 布局:三列栅格,一行三个字段;不分组(与菜单基础信息同款理由)——
* 分组标题只是把十来个字段切成几段,字段本身并没有多到需要分段,去掉反而更紧凑。
* 多语言键(b_i18n)不在界面上显示 —— 它由编码派生、用户改不了,
* 占一个字段位只是噪音(派生逻辑照旧,见 onCodeInput)。
*
* 启用态(b_canuse)不在本表单里:它在左树行尾勾选(点一下即时写库)与卡头徽标展示。
* 表单里再放一个开关会与之语义冲突 —— 树里那个立即生效,表单这个要等「保存」,
* 同一字段两种口径用户没法预期;且草稿态启停本身是伪需求(启停是运维动作,不是配置内容)。
*
* 校验规则(随输入实时提示;保存前由父级调 validate() 统一跑一遍):
* - 编码:必填、2-50 位、小写字母开头、只含小写字母/数字/下划线、禁止负数临时编码;
* - 名称 / 类型必填;data 的查询来源(表或 SQL)、主键字段、保存表必填。
*/
import { computed, ref } from 'vue'
import { G3Form, G3FormGroup, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui'
import { G3Form, G3FormItem, G3Input, G3Select } from '@g3soft/ui'
import type { FormRule } from '@g3soft/ui'
import { buildModuleI18nKey, type ModuleFieldRow, type ModuleNode } from './utils'
@@ -131,116 +140,99 @@ function onModuleTypeChange(value: unknown) {
max-width="720px"
label-width="96px"
>
<G3FormGroup title="标识与归属">
<G3FormItem label="模块编码" name="b_id" required>
<G3Input
:model-value="moduleModel.b_id ?? ''"
:disabled="codeReadonly"
@update:model-value="onCodeInput"
/>
</G3FormItem>
<G3FormItem label="模块名称" name="b_name" required>
<G3Input
:model-value="moduleModel.b_name ?? ''"
@update:model-value="(value) => setField('b_name', value)"
/>
</G3FormItem>
<G3FormItem label="模块类型" name="b_module_type" required>
<G3Select
:model-value="moduleModel.b_module_type ?? ''"
:options="typeOptions"
@update:model-value="onModuleTypeChange"
/>
</G3FormItem>
<G3FormItem label="多语言键" name="b_i18n" :span="2">
<G3Input :model-value="moduleModel.b_i18n ?? ''" readonly />
</G3FormItem>
</G3FormGroup>
<G3FormItem label="模块编码" name="b_id" required>
<G3Input
:model-value="moduleModel.b_id ?? ''"
:disabled="codeReadonly"
@update:model-value="onCodeInput"
/>
</G3FormItem>
<G3FormItem label="模块名称" name="b_name" required>
<G3Input
:model-value="moduleModel.b_name ?? ''"
@update:model-value="(value) => setField('b_name', value)"
/>
</G3FormItem>
<G3FormItem label="模块类型" name="b_module_type" required>
<G3Select
:model-value="moduleModel.b_module_type ?? ''"
:options="typeOptions"
@update:model-value="onModuleTypeChange"
/>
</G3FormItem>
<G3FormGroup v-if="hasDataSource" title="数据来源">
<G3FormItem label="查询表/视图" name="b_view_table" :required="isData">
<G3Input
:model-value="moduleModel.b_view_table ?? ''"
@update:model-value="(value) => setField('b_view_table', value)"
/>
</G3FormItem>
<G3FormItem label="主键字段" name="b_key_field" :required="isData">
<G3Input
:model-value="moduleModel.b_key_field ?? ''"
@update:model-value="(value) => setField('b_key_field', value)"
/>
</G3FormItem>
<!-- 数据范围字段:记录归属人字段(如录入人)。
用户数据范围面板只选「看到哪一档」,用哪个字段判在这里定 ——
同一个模块的所有用户必须用同一字段,否则同一份数据对不同用户含义不同 -->
<G3FormItem label="数据范围字段" name="b_scope_field">
<G3Select
:model-value="moduleModel.b_scope_field ?? ''"
:options="scopeFieldOptions"
clearable
title="记录归属人字段:「仅本人」按它比对,「本部门 / 本部门及下级」由该用户反查部门;留空表示本模块不按字段判范围"
@update:model-value="(value) => setField('b_scope_field', value)"
/>
</G3FormItem>
<G3FormItem v-if="isData" label="保存表" name="b_save_table" required>
<G3Input
:model-value="moduleModel.b_save_table ?? ''"
@update:model-value="(value) => setField('b_save_table', value)"
/>
</G3FormItem>
<G3FormItem label="默认排序 SQL" name="b_order_sql" :span="isData ? 1 : 3">
<G3Input
:model-value="moduleModel.b_order_sql ?? ''"
@update:model-value="(value) => setField('b_order_sql', value)"
/>
</G3FormItem>
</G3FormGroup>
<!-- 数据来源:data / virtual 才有 -->
<G3FormItem v-if="hasDataSource" label="查询表/视图" name="b_view_table" :required="isData">
<G3Input
:model-value="moduleModel.b_view_table ?? ''"
@update:model-value="(value) => setField('b_view_table', value)"
/>
</G3FormItem>
<G3FormItem v-if="hasDataSource" label="主键字段" name="b_key_field" :required="isData">
<G3Input
:model-value="moduleModel.b_key_field ?? ''"
@update:model-value="(value) => setField('b_key_field', value)"
/>
</G3FormItem>
<!-- 数据范围字段:记录归属人字段(如录入人)。
用户数据范围面板只选「看到哪一档」,用哪个字段判在这里定 ——
同一个模块的所有用户必须用同一字段,否则同一份数据对不同用户含义不同 -->
<G3FormItem v-if="hasDataSource" label="数据范围字段" name="b_scope_field">
<G3Select
:model-value="moduleModel.b_scope_field ?? ''"
:options="scopeFieldOptions"
clearable
title="记录归属人字段:「仅本人」按它比对,「本部门 / 本部门及下级」由该用户反查部门;留空表示本模块不按字段判范围"
@update:model-value="(value) => setField('b_scope_field', value)"
/>
</G3FormItem>
<G3FormItem v-if="isData" label="保存表" name="b_save_table" required>
<G3Input
:model-value="moduleModel.b_save_table ?? ''"
@update:model-value="(value) => setField('b_save_table', value)"
/>
</G3FormItem>
<G3FormItem v-if="hasDataSource" label="默认排序 SQL" name="b_order_sql">
<G3Input
:model-value="moduleModel.b_order_sql ?? ''"
@update:model-value="(value) => setField('b_order_sql', value)"
/>
</G3FormItem>
<G3FormGroup v-if="hasDataSource" title="查询与扩展">
<G3FormItem label="查询 SQL" name="b_query_sql" :span="3">
<G3Input
type="textarea"
:rows="3"
:model-value="moduleModel.b_query_sql ?? ''"
@update:model-value="(value) => setField('b_query_sql', value)"
/>
</G3FormItem>
<G3FormItem label="扩展配置 JSON" name="b_config_json" :span="3">
<G3Input
type="textarea"
:rows="2"
:model-value="moduleModel.b_config_json ?? ''"
@update:model-value="(value) => setField('b_config_json', value)"
/>
</G3FormItem>
</G3FormGroup>
<!-- 查询 SQL / 扩展配置:内容较长,整行铺满 -->
<G3FormItem v-if="hasDataSource" label="查询 SQL" name="b_query_sql" :span="3">
<G3Input
type="textarea"
:rows="3"
:model-value="moduleModel.b_query_sql ?? ''"
@update:model-value="(value) => setField('b_query_sql', value)"
/>
</G3FormItem>
<G3FormItem v-if="hasDataSource" label="扩展配置 JSON" name="b_config_json" :span="3">
<G3Input
type="textarea"
:rows="2"
:model-value="moduleModel.b_config_json ?? ''"
@update:model-value="(value) => setField('b_config_json', value)"
/>
</G3FormItem>
<G3FormGroup title="行为设置">
<G3FormItem label="排序号" name="b_xh">
<G3Input
type="number"
:model-value="moduleModel.b_xh ?? ''"
@update:model-value="(value) => setField('b_xh', value)"
/>
</G3FormItem>
<G3FormItem label="启用" name="b_canuse">
<G3Switch
:model-value="Number(moduleModel.b_canuse) === 1"
@update:model-value="(value) => setField('b_canuse', value ? 1 : 0)"
/>
</G3FormItem>
</G3FormGroup>
<G3FormItem label="排序号" name="b_xh">
<G3Input
type="number"
:model-value="moduleModel.b_xh ?? ''"
@update:model-value="(value) => setField('b_xh', value)"
/>
</G3FormItem>
<G3FormGroup title="更多">
<G3FormItem label="描述" name="b_bz" :span="3">
<G3Input
type="textarea"
:rows="2"
:model-value="moduleModel.b_bz ?? ''"
@update:model-value="(value) => setField('b_bz', value)"
/>
</G3FormItem>
</G3FormGroup>
<G3FormItem label="描述" name="b_bz" :span="3">
<G3Input
type="textarea"
:rows="2"
:model-value="moduleModel.b_bz ?? ''"
@update:model-value="(value) => setField('b_bz', value)"
/>
</G3FormItem>
</G3Form>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -6,14 +6,26 @@
* 职责:树的展示与展开 / 选中 / 拖拽 / 右键菜单的 UI 交互;搜索(关键字、过滤、
* 搜索时展开)由 BizTreePanel 内部完成,本组件不再持有搜索状态。
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
* select / add / delete / drop。
* select / add / delete / drop / toggle-enabled。
* 树数据加载、新增/删除落库、拖拽排序持久化及跨面板联动仍由父级(index.vue)决定。
* 展开键需由父级在新建模块后追加祖先路径,故以 v-model:expanded-keys 暴露。
* 折叠态以 v-model:collapsed 透传给 BizTreePanel(宽度动画由页面负责)。
* 「新增」是多类型下拉,经 #add 插槽替换 BizTreePanel 的内置按钮。
*
* 启用勾选(行尾):
* 不用 G3Tree 内置的 `checkable` —— 那个勾选框落在箭头与名称之间(行首),
* 视觉上是「多选这一批节点」的语义;本处的勾选是逐行切换该模块的启用态,
* 属于行尾的状态开关,所以自己在 #title 插槽里画,位置也更贴近它作用的这一行。
* 代价是要自己处理三件事:① 点击不能冒泡(否则会连带选中行 / 触发拖拽);
* ② 草稿节点不可勾(库里还没有这行);③ 与「未保存」圆点共处一行时的排布。
*
* 注意面板上的 `block-node` 是勾选能贴到最右边的前提,不能去掉:
* G3Tree 的节点内容层默认是 `inline-flex` 且不带 flex:1(宽度=文字宽度),
* 只有 blockNode 才给它 `flex: 1`;少了它,下面 .mm-tree-node 的 flex:1
* 撑的是「贴着文字的那一小段」,勾选框会紧跟名称、而不是贴到行尾。
*/
import { Plus, Trash2 } from '@lucide/vue'
import { G3Button, G3Dropdown } from '@g3soft/ui'
import { G3Button, G3Checkbox, G3Dropdown } from '@g3soft/ui'
import type {
DropdownClickPayload,
TreeContextMenuItem,
@@ -54,8 +66,24 @@ const emit = defineEmits<{
add: [payload: { parentId: string | null; moduleType: string }]
delete: [id: string | null]
drop: [payload: TreeDropInfo]
/** 行尾勾选:切换某模块的启用态(父级负责落库并回写 b_canuse) */
toggleEnabled: [payload: { id: string; enabled: boolean }]
}>()
/** 节点是否可勾选:未落库草稿在库里没有行,改不了它的 b_canuse */
function isNodeCheckable(node: TreeNodeData): boolean {
return !props.unsavedIds.has(String((node as ModuleNode).b_id))
}
/**
* 行尾勾选。
* 勾不起草稿节点(父级据此提示),其余照常上报,由父级决定落库。
*/
function onToggleEnabled(node: TreeNodeData, enabled: boolean) {
if (!isNodeCheckable(node)) return
emit('toggleEnabled', { id: String((node as ModuleNode).b_id), enabled })
}
function onExpandedKeysChange(keys: TreeKey[]) {
emit('update:expandedKeys', keys)
}
@@ -119,6 +147,7 @@ function onDrop(info: TreeDropInfo) {
title="模块"
placeholder="搜索模块"
draggable
block-node
:tree-data="treeData"
:loading="loading"
:expanded-keys="expandedKeys"
@@ -156,6 +185,24 @@ function onDrop(info: TreeDropInfo) {
title="未保存"
aria-label="未保存"
/>
<!-- 行尾启用勾选:快捷切换本模块的启用态。
@click.stop 必须留:行上挂了 click(选中)与 mousedown(拖拽武装),
不拦就会「勾一下顺带把这一行也选中 / 拖起来」。
title 随状态变化,鼠标悬停能看清这一下会切到哪一态。 -->
<span class="mm-tree-node__enable" @click.stop>
<G3Checkbox
:model-value="Number((node as ModuleNode).b_canuse) === 1"
:disabled="!isNodeCheckable(node)"
:title="
isNodeCheckable(node)
? Number((node as ModuleNode).b_canuse) === 1
? '点击停用该模块'
: '点击启用该模块'
: '未保存的模块不能启用/停用'
"
@update:model-value="(value) => onToggleEnabled(node, value)"
/>
</span>
</span>
</template>
</BizTreePanel>
@@ -198,4 +245,22 @@ function onDrop(info: TreeDropInfo) {
border-radius: 50%;
background: var(--g3-color-warning, #faad14);
}
/* 行尾启用勾选:不参与压缩,始终贴在行尾。
* 不做「hover 才显形」——树里散布着若干勾选框时,静态可见才能一眼扫出
* 哪些模块是停用的;隐藏到 hover 反而要逐行划过才知道。 */
.mm-tree-node__enable {
display: inline-flex;
align-items: center;
flex: none;
/* 负外边距抵消 G3Checkbox 的 8px gap/内边距,避免行尾多出一段空白 */
margin-left: 2px;
}
/* 勾选框比正文略小,避免在 28px 行高里显突兀;
* 无文案(未传默认插槽)时 G3Checkbox 是 inline-flex + gap,
* 这里只留勾选框本体,把多余间距压掉。 */
.mm-tree-node__enable :deep(.g3-checkbox) {
gap: 0;
}
</style>
@@ -5,13 +5,15 @@
* 当前已落地左侧模块树:加载(s_module)/ 搜索 / 新增草稿 / 删除 / 拖拽排序 / 未保存标记;
* 右侧已搭好配置骨架:横向标签导航(nav-only,标签集随模块类型增减)+ 常驻 pane 堆叠区,
* 各配置面板(基础 / 字段 / 列表 / 表单 / 查询 / 自动编码 / 权限 / 关联 / 规则 / 多语言)
* 与保存编排将按 fms-vue 的 module-management 逐节填进对应 pane;届时本文件按 fms 的分层
* 将按 fms-vue 的 module-management 逐节填进对应 pane,当前已落地基础 / 字段 / 查询 /
* 自动编码(各面板只做编辑,改动落在父级持有的草稿上);保存编排落地时本文件按 fms 的分层
* 补齐「当前编辑值 / _org 基线成对持有 + 保存时逐表 diff」的编排职责。
*
* 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块。
*/
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Ellipsis, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { Ellipsis, Eye, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue'
import { useHotkeys } from '@/composables/useHotkeys'
import {
G3Button,
G3Dropdown,
@@ -19,6 +21,7 @@ import {
G3Modal,
G3Splitter,
G3SplitterPanel,
G3Switch,
G3Tabs,
G3Tag,
} from '@g3soft/ui'
@@ -26,7 +29,12 @@ import type { DropdownClickPayload, TreeDropInfo, TreeKey } from '@g3soft/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
import { createTempId } from '@/utils/common'
import { buildPowerDeleteDependents } from '@/utils/powerCodes'
import { normalizeGroupIds, parseSchema } from '@/utils/schemaJson'
import {
createDefaultSchema,
normalizeGroupIds,
parseSchema,
type SchemaJson,
} from '@/utils/schemaJson'
import {
applyTreePosition,
buildSortedTree,
@@ -42,6 +50,7 @@ import {
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
import ModuleBasicPanel from './ModuleBasicPanel.vue'
import ModuleFieldsPanel from './ModuleFieldsPanel.vue'
import ModuleQueryPanel from './ModuleQueryPanel.vue'
import ModuleTreePanel from './ModuleTreePanel.vue'
import {
MODULE_NON_LEAF_TYPES,
@@ -271,7 +280,7 @@ const CONFIG_SECTIONS: ConfigSection[] = [
{
key: 'query',
label: '查询配置',
desc: '配置查询条件与默认查询参数。',
desc: '配置列表页查询入口:条件字段、工作队列页签、关键词与快捷筛选。',
scope: 'config',
},
{
@@ -347,14 +356,49 @@ const moduleFields = ref<ModuleFieldRow[]>([])
/** 选中模块的自动编码规则草稿(s_autocode,一模块至多一条;空数组即未配置) */
const moduleAutoCode = ref<ModuleAutoCodeRow[]>([])
/** 自动编码面板实例:保存前调它的 validate() 拿表单错误 */
const autoCodePanelRef = ref<InstanceType<typeof ModuleAutoCodePanel> | null>(null)
/** 选中模块的查询设置草稿(s_module_schema.b_schema_type = 'query' 的配置对象) */
const moduleQuery = ref<SchemaJson>(createDefaultSchema('query'))
/**
* 自动编码的启用态:开关挂在面板头(与「同步字段」「预览查询入口」同一操作区),
* 状态由面板回报(@update:enabled),拨动时调面板暴露的 toggleEnabled,
* 面板负责惰性建行与写 b_canuse——父级不碰 s_autocode 行的构造规则。
*/
const autoCodeEnabled = ref(false)
function onToggleAutoCode(value: boolean) {
autoCodePanel.value?.toggleEnabled(value)
}
/**
* 面板实例引用:自动编码(保存前 validate / 面板头 toggleEnabled)、
* 查询设置(面板头预览弹窗)。
*
* 必须用「函数 ref」而不是 `ref="name"`:两个面板都写在
* `v-for="section in configSections"` 里面,模板编译器会给标签加上
* `ref_for: true`,此时 Vue 赋给 ref 的是**实例数组**而不是单个实例——
* 于是 `autoCodePanelRef.value?.toggleEnabled(...)` 变成
* 「数组上找 toggleEnabled」→ undefined → is not a function。
* 函数 ref 由自己接收实例,不触发 ref_for 的数组收集,天然免疫。
*/
const autoCodePanel = shallowRef<InstanceType<typeof ModuleAutoCodePanel> | null>(null)
const queryPanel = shallowRef<InstanceType<typeof ModuleQueryPanel> | null>(null)
/** 函数 ref:模板里写 :ref="setAutoCodePanel"(不能用 ref="autoCodePanel") */
function setAutoCodePanel(instance: unknown) {
autoCodePanel.value = (instance as InstanceType<typeof ModuleAutoCodePanel>) ?? null
}
function setQueryPanel(instance: unknown) {
queryPanel.value = (instance as InstanceType<typeof ModuleQueryPanel>) ?? null
}
/** 选中变化:重建草稿并拉字段 / 自动编码(异步响应带过期保护,切走即丢弃) */
watch(moduleSelectedId, (id) => {
moduleDraft.value = id == null ? null : toModuleDraft(String(id))
void loadModuleFields(id)
void loadModuleAutoCode(id)
void loadModuleQuery(id)
})
function toModuleDraft(id: string): ModuleNode | null {
@@ -386,6 +430,7 @@ async function loadModuleFields(id: string | null) {
*/
async function loadModuleAutoCode(id: string | null) {
moduleAutoCode.value = []
autoCodeEnabled.value = false
if (id == null) return
if (findModuleNode(id)?.b_module_type !== 'data') return
const requested = String(id)
@@ -398,11 +443,34 @@ async function loadModuleAutoCode(id: string | null) {
// 选择已切走时丢弃过期响应
if (String(moduleSelectedId.value ?? '') !== requested) return
moduleAutoCode.value = (rows ?? []) as ModuleAutoCodeRow[]
// 启用态随规则一起落定:规则行可能存在但 b_canuse = 0(配过又停用)
autoCodeEnabled.value = Number(moduleAutoCode.value[0]?.b_canuse) === 1
} catch {
// 规则加载失败不打断页面:面板按「未配置」展示
}
}
/**
* 加载本模块的查询设置(s_module_schema 的 query 行)。
* 没有该行即「未配置」,用默认空结构;查询设置目前只做编辑,不回写库。
*/
async function loadModuleQuery(id: string | null) {
moduleQuery.value = createDefaultSchema('query')
if (id == null) return
const requested = String(id)
try {
const rows = await loadDataApi(
's_module_schema',
`b_module_id = ${sqlStringLiteral(requested)} AND b_schema_type = ${sqlStringLiteral('query')}`,
)
// 选择已切走时丢弃过期响应
if (String(moduleSelectedId.value ?? '') !== requested) return
moduleQuery.value = parseSchema('query', rows?.[0]?.b_schema_json)
} catch {
// 查询设置加载失败不打断页面:面板按「未配置」展示
}
}
/* ================= 卡头动作 ================= */
/** 卡头「更多」菜单:模块级动作(删除与左侧树右键菜单是同一入口) */
@@ -432,7 +500,7 @@ function validateAutoCode(): string {
* 目前先把自动编码的校验串上:有错就跳到对应标签并高亮。
*/
async function onSave() {
const autoCodeErrors = await autoCodePanelRef.value?.validate()
const autoCodeErrors = await autoCodePanel.value?.validate()
if (autoCodeErrors?.length) {
activeSection.value = 'auto-code'
G3Message.warning('请修正表单中标红的错误项')
@@ -452,6 +520,17 @@ function onRefresh() {
G3Message.info('配置重新加载随配置面板逐步移植')
}
/* ================= 快捷键 ================= */
// ignoreWhenTyping 关掉:输入框里改完配置直接 Ctrl+S 保存,不用先点空白处
useHotkeys([
{
key: 's',
mod: true,
ignoreWhenTyping: false,
handler: () => void onSave(),
},
])
/**
* 字段定义面板的「同步字段」:按数据库查询表 / 视图结构对齐字段定义。
*
@@ -580,6 +659,44 @@ async function deleteModule(id: string | null) {
})
}
/**
* 树节点行尾勾选:即时切换模块启用态。
*
* 「快捷」的意义就在于点一下立刻生效,所以这里直接写库,不走页面的草稿保存编排。
* 三处状态必须一起改,否则会出现「树里关了、卡头还显示启用」:
* ① 树节点(moduleTreeData 里的 ModuleNode)
* ② 右侧卡片的草稿副本(moduleDraft 是「当前编辑值」,headModule 读的就是它)
* ③ 服务端(s_module.b_canuse)
* 先改本地再写库:勾选是即时反馈的交互,等一个来回会有明显的迟滞感。
* 写库失败则把两者都回滚成原值,并把错误抛给用户——不能留下「界面关了、库里还开着」。
*/
async function onToggleModuleEnabled({ id, enabled }: { id: string; enabled: boolean }) {
const node = findModuleNode(id)
if (!node) return
const previous = Number(node.b_canuse) === 1
if (previous === enabled) return
const nextValue = enabled ? 1 : 0
node.b_canuse = nextValue
// 草稿是浅拷贝,改树节点不会传到它;只同步当前选中的那个模块
if (String(moduleDraft.value?.b_id ?? '') === id) {
moduleDraft.value = { ...moduleDraft.value, b_canuse: nextValue } as ModuleNode
}
try {
await saveObjectApi([
{ table: 's_module', key_field: 'b_id', updates: [{ b_id: id, b_canuse: nextValue }] },
])
G3Message.success(enabled ? '模块已启用' : '模块已停用')
} catch (e) {
node.b_canuse = previous ? 1 : 0
if (String(moduleDraft.value?.b_id ?? '') === id) {
moduleDraft.value = { ...moduleDraft.value, b_canuse: previous ? 1 : 0 } as ModuleNode
}
G3Message.error('切换启用状态失败:' + errorText(e))
}
}
async function onModuleDrop({ dragKey, dropKey, dropPosition }: TreeDropInfo) {
// 成环 / 类型 / 未保存守卫与 b_xh、b_depth、b_path、b_parent_id 的重算都在 computeTreeMove 内
const result = computeTreeMove(moduleTreeData.value, {
@@ -642,6 +759,7 @@ onMounted(() => {
@add="({ parentId, moduleType }) => addModule(parentId, moduleType)"
@delete="(id) => void deleteModule(id)"
@drop="(payload) => void onModuleDrop(payload)"
@toggle-enabled="(payload) => void onToggleModuleEnabled(payload)"
/>
</G3SplitterPanel>
@@ -722,6 +840,27 @@ onMounted(() => {
同步字段
</G3Button>
</div>
<!-- 查询设置的预览也挂在面板头:面板内部只画形态,动作与其它面板放同一处 -->
<div v-else-if="section.key === 'query'" class="mm-panel-head__actions">
<G3Button
type="outline"
title="按当前配置预览列表页的查询入口"
@click="queryPanel?.openPreview()"
>
<template #icon><Eye :size="14" /></template>
预览查询入口
</G3Button>
</div>
<!-- 自动编码的启用开关:本面板的总开关,与它管辖的参数同级铺在网格里
会被读成「其中一个字段」,提到面板头与其它面板的动作同一处 -->
<div v-else-if="section.key === 'auto-code'" class="mm-panel-head__actions">
<span class="mm-panel-head__switch-label">启用</span>
<G3Switch
:model-value="autoCodeEnabled"
:disabled="moduleTreeLoading"
@update:model-value="onToggleAutoCode"
/>
</div>
</div>
<!-- 基础信息:第一个真实面板(保存编排落地前先只做 UI,编辑只落在本地草稿) -->
@@ -736,13 +875,23 @@ onMounted(() => {
v-else-if="section.key === 'fields'"
v-model:fields="moduleFields"
/>
<!-- 查询设置:两屏(查询字段 / 工作队列页签),全字段勾选式,只做编辑不回写 -->
<ModuleQueryPanel
v-else-if="section.key === 'query'"
:ref="setQueryPanel"
v-model:query="moduleQuery"
:module-id="String(moduleDraft?.b_id ?? '')"
:fields="moduleFields"
/>
<!-- 自动编码:仅数据模块(configSections 已按 scope 过滤),单条规则 + 实时预览 -->
<ModuleAutoCodePanel
v-else-if="section.key === 'auto-code'"
ref="autoCodePanelRef"
:ref="setAutoCodePanel"
v-model:autocode="moduleAutoCode"
:enabled="autoCodeEnabled"
:module-id="String(moduleDraft?.b_id ?? '')"
:fields="moduleFields"
@update:enabled="(value) => (autoCodeEnabled = value)"
/>
<div v-else class="mm-config-placeholder">
{{ section.label }}面板将按 fms-vue 逐步移植
@@ -915,6 +1064,12 @@ onMounted(() => {
margin-left: auto;
}
/* 自动编码开关的标签:与 G3Switch 同排,字号对齐面板头的说明文案 */
.mm-panel-head__switch-label {
color: var(--g3-color-text, #1f2329);
font-size: 13px;
}
.mm-config-pane {
display: flex;
flex: 1;
@@ -1,13 +1,19 @@
/**
* 模块管理公共工具(移植自 fms-vue 的 module-management/utils.js,按需逐步补齐)。
*
* 当前提供:模块树所需的类型 / 图标 / 新增菜单项,以及删除模块时的多语言键收集
* (模块名键、字段键、界面配置分组键)——后续面板(字段 / 基础 / 多语言等)用到
* 的其它工具随对应步骤补充。
* 当前提供:模块树所需的类型 / 图标 / 新增菜单项,删除模块时的多语言键收集
* (模块名键、字段键、界面配置分组键),以及查询设置(query schema)的草稿模型
* ——后续面板(列表 / 表单 / 权限等)用到的其它工具随对应步骤补充。
*/
import { Database, Folder, FolderPlus, Search } from '@lucide/vue'
import type { Component } from 'vue'
import { buildGroupI18nKey, type SchemaJson, type SchemaNode } from '@/utils/schemaJson'
import {
buildGroupI18nKey,
createDefaultSchema,
type SchemaJson,
type SchemaNode,
} from '@/utils/schemaJson'
import { createTempId } from '@/utils/common'
import type { TreeRow } from '@/utils/tree'
/** 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块 */
@@ -94,6 +100,295 @@ export interface ModuleAutoCodeRow {
[key: string]: unknown
}
/* ================= 查询设置(s_module_schema.b_schema_type = 'query') =================
* 查询配置在编辑器里是一份「扁平草稿」(ModuleQueryDraft,四块内容各一个数组/对象),
* 落库形态是 query JSON(schemaVersion 3);两个方向的转换集中在这里,
* 口径与 fms-vue 的 schemaModel.js 一致。
*
* 四块内容:
* - fields(查询字段):参与条件查询的字段;未进快捷筛选的,运行时按 b_xh 铺进「更多筛选」抽屉;
* - views(工作队列页签):组内单选、组间叠加,条件直接写 SQL;
* - keyword(关键词字段):只配可搜索字段,占位文案运行时按字段名生成;
* - quickFilters(快捷筛选字段):铺在列表页查询卡上,按 order 排列。
*
* 未建模属性经行上的 _node 原样带回:草稿只承载面板认识的列,
* JSON 里未来的扩展键不会在编辑往返中被静默丢掉。
*/
/**
* 查询组件与操作符(queryFields[字段].component / .operator)。
*
* 没有界面入口——查询控件由字段的业务类型决定即可(日期字段当然是日期框、下拉字段当然是下拉),
* 配置里已有的值由面板原样保留、不回写不认识的东西,这里只给「新参与查询的字段」兜一个初值。
* 取值:组件 input 输入框 / select 下拉 / date 日期 / datetime 日期时间 / number 数字 / checkbox 复选框;
* 操作符 eq 等于 / like 模糊 / starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。
*/
/** 按字段业务类型给出查询组件 / 操作符默认值;未列出的(input / textarea)走通用 like */
const QUERY_COMPONENT_DEFAULTS: Record<string, { component: string; operator: string }> = {
number: { component: 'number', operator: 'eq' },
money: { component: 'number', operator: 'eq' },
date: { component: 'date', operator: 'ge' },
datetime: { component: 'datetime', operator: 'ge' },
select: { component: 'select', operator: 'eq' },
checkbox: { component: 'checkbox', operator: 'eq' },
}
/** 字段 → 查询组件 / 操作符默认值(新参与查询的字段按业务类型兜初值) */
export function queryRowDefaults(field: ModuleFieldRow): {
b_component: string
b_operator: string
} {
const type = String(field?.b_type ?? '')
const defaults = QUERY_COMPONENT_DEFAULTS[type] || { component: 'input', operator: 'like' }
return { b_component: defaults.component, b_operator: defaults.operator }
}
/**
* 页签所属分组:模型上页签「一定有组」——组既是列表页上的一行页签,
* 也是「组内单选、组间叠加」的作用域,不存在不属于任何组的页签。
* 历史配置里没写 group 的,落到这个默认组,落库时补上。
*/
export const DEFAULT_VIEW_GROUP = '默认'
/**
* 快捷搜索项在卡上的呈现方式:
* control = 直接把控件摆出来(用户自己填),preset = 给一排相对时间的预设(点一下就筛)。
*/
export const QUERY_QUICK_MODES = [
{ label: '直接放控件', value: 'control' },
{ label: '给预设', value: 'preset' },
]
/**
* 相对时间的预设。存的**是枚举不是日期**:配的时候不知道用户哪天开页面,
* 运行时才算成具体区间(如 this_month → 本月 1 号 ~ 今天)。
*/
export const QUERY_DATE_PRESETS = [
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'this_week', label: '本周' },
{ value: 'last_week', label: '上周' },
{ value: 'this_month', label: '本月' },
{ value: 'last_month', label: '上月' },
{ value: 'last_7_days', label: '最近7天' },
{ value: 'last_30_days', label: '最近30天' },
]
/** 查询字段行(草稿):身份是 _rowKey,落库后按 b_field 收进 queryFields */
export interface ModuleQueryFieldRow {
_rowKey: string
/** 未建模属性(原 queryFields[字段] 的其余键),回写时原样带回 */
_node: Record<string, unknown>
b_module_id: string
b_field: string | null
b_component: string
b_operator: string
b_default_value: string | null
/** 卡上的呈现方式:control 直接放控件 / preset 给一排预设(不写按 control) */
b_mode: string
/** preset 模式下要露出的预设(相对时间枚举) */
b_presets: string[]
/** preset 模式下默认选中哪个预设(空 = 打开页面不带条件) */
b_default_preset: string | null
b_canuse: number
b_xh: number
/**
* 由「列表」勾选自动补出的行(fms 语义):面板里改过组件 / 算子 / 默认值后摘掉标记,
* 转为自定义行,取消列表勾选时不再被自动回收。列表面板尚未移植,
* 保留该列只为与落库结构一致。
*/
fromList: boolean
[key: string]: unknown
}
/** 工作队列页签行(草稿):code 为空的行不落库(页签没有编码就无法被运行时选中) */
export interface ModuleQueryViewRow {
_rowKey: string
_node: Record<string, unknown>
code: string
label: string
group: string
order: number
showCount: boolean
isDefault: boolean
conditionSql: string
[key: string]: unknown
}
/** 快捷筛选行(草稿) */
export interface ModuleQueryQuickRow {
_rowKey: string
field: string
order: number
[key: string]: unknown
}
/** 查询设置草稿:四个分节的编辑态,与落库 JSON 经下面两个函数双向适配 */
export interface ModuleQueryDraft {
fields: ModuleQueryFieldRow[]
views: ModuleQueryViewRow[]
keyword: { fields: string[] }
quickFilters: ModuleQueryQuickRow[]
}
/** query JSON → 编辑器草稿 */
export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string): ModuleQueryDraft {
const source = (schema && typeof schema === 'object' ? schema : {}) as SchemaJson & {
queryFields?: Record<string, unknown>
views?: Array<Record<string, unknown>>
keywordSearch?: { fields?: unknown[] }
quickFilters?: Array<Record<string, unknown>>
defaultViewCode?: string
}
const fieldRows = Object.entries(source.queryFields || {}).map(([field, config], index) => {
const node =
config && typeof config === 'object' ? { ...(config as Record<string, unknown>) } : {}
const fromList = node.fromList === true
delete node.fromList
return {
_rowKey: createTempId(),
_node: node,
b_module_id: String(moduleId),
b_field: field,
b_component: String(node.component ?? 'input'),
b_operator: String(node.operator ?? 'like'),
b_default_value: node.defaultValue == null ? null : String(node.defaultValue),
b_mode: String(node.mode ?? 'control'),
b_presets: Array.isArray(node.presets) ? node.presets.map((item) => String(item)) : [],
b_default_preset: node.defaultPreset == null ? null : String(node.defaultPreset),
b_canuse: 1,
b_xh: (index + 1) * 10,
fromList,
} as ModuleQueryFieldRow
})
// 旧结构的 defaultViewCode 等价于「该页签 isDefault」,加载时折进来
const legacyDefaultCode = String(source.defaultViewCode ?? '')
const views = (source.views || []).map((view, index) => {
const code = String(view?.code ?? '')
return {
_rowKey: createTempId(),
_node: view && typeof view === 'object' ? { ...view } : {},
code,
label: String(view?.label ?? ''),
group: String(view?.group ?? '').trim() || DEFAULT_VIEW_GROUP,
order: Number(view?.order) || (index + 1) * 10,
showCount: view?.showCount === true,
isDefault: view?.isDefault === true || (legacyDefaultCode !== '' && code === legacyDefaultCode),
conditionSql: String(view?.conditionSql ?? '').trim(),
} as ModuleQueryViewRow
})
return {
fields: fieldRows,
views,
keyword: {
fields: (source.keywordSearch?.fields || []).map((field) => String(field)),
},
quickFilters: (source.quickFilters || []).map((entry, index) => ({
_rowKey: createTempId(),
field: String(entry?.field ?? ''),
order: Number(entry?.order) || (index + 1) * 10,
})),
}
}
/** 编辑器草稿 → query JSON(未建模属性经 _node / base 保留,建模属性以草稿为准) */
export function queryDraftToSchema(
draft: ModuleQueryDraft | null,
baseSchema?: SchemaJson | null,
): SchemaJson {
const base = { ...(baseSchema || createDefaultSchema('query')) }
// 旧结构的这几个键一律不再使用,改写时一并清掉
delete base.groups
delete base.quick
delete base.filterGroups
delete base.defaultViewCode
const queryFields: Record<string, unknown> = {}
for (const row of draft?.fields || []) {
const field = String(row.b_field ?? '').trim()
if (!field) continue
const config: Record<string, unknown> = { ...(row._node || {}) }
if (row.b_component) config.component = row.b_component
else delete config.component
if (row.b_operator) config.operator = row.b_operator
else delete config.operator
if (row.b_default_value != null && row.b_default_value !== '') {
config.defaultValue = row.b_default_value
} else {
delete config.defaultValue
}
// 呈现方式与预设:control 是默认形态,不写进 JSON(省得每条都挂一堆默认值)
if (row.b_mode === 'preset') {
config.mode = 'preset'
const presets = (row.b_presets || []).map((item) => String(item)).filter(Boolean)
if (presets.length) config.presets = presets
else delete config.presets
if (row.b_default_preset) config.defaultPreset = row.b_default_preset
else delete config.defaultPreset
} else {
delete config.mode
delete config.presets
delete config.defaultPreset
}
if (row.fromList) config.fromList = true
else delete config.fromList
queryFields[field] = config
}
const views = (draft?.views || [])
.map((row) => {
const code = String(row.code ?? '').trim()
if (!code) return null
const view: Record<string, unknown> = { ...(row._node || {}), code }
if (String(row.label ?? '').trim()) view.label = String(row.label).trim()
else delete view.label
// 页签一定有分组:空的一律落到默认组(历史配置在此被补全)
view.group = String(row.group ?? '').trim() || DEFAULT_VIEW_GROUP
view.order = Number(row.order) || 10
if (row.showCount) view.showCount = true
else delete view.showCount
if (row.isDefault) view.isDefault = true
else delete view.isDefault
const conditionSql = String(row.conditionSql ?? '').trim()
if (conditionSql) view.conditionSql = conditionSql
else delete view.conditionSql
return view
})
.filter((view): view is Record<string, unknown> => view != null)
const keywordFields = [
...new Set((draft?.keyword?.fields || []).map((field) => String(field)).filter(Boolean)),
]
return {
...base,
schemaVersion: 3,
views,
keywordSearch: { fields: keywordFields },
quickFilters: (draft?.quickFilters || [])
.map((row, index) => ({
field: String(row.field ?? '').trim(),
order: Number(row.order) || (index + 1) * 10,
}))
.filter((entry) => entry.field),
queryFields,
}
}
/**
* 校验页签条件 SQL:只拦注入类写法(分号 / 注释),不校验字段与语法。
* 返回 true 通过,返回字符串即错误文案。
*/
export function validateConditionSql(text: string): true | string {
const value = String(text ?? '').trim()
if (!value) return true
if (value.includes(';')) return '不能包含分号'
if (value.includes('--')) return '不能包含单行注释 --'
if (/\/\*/.test(value)) return '不能包含块注释 /* */'
return true
}
/* ================= 多语言资源键(s_i18n,主键 b_key + b_locale) ================= */
export function buildModuleItemI18nKey(