u
This commit is contained in:
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>
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user