20260821230656

This commit is contained in:
oneao committed 2026-08-21 23:06:56 +08:00
1 parent 2e5d532f23
commit c43b4c8364
144 files changed
+7665 -242

No files matched your search

@@ -0,0 +1,37 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- heading "登录 FMS" [level=1] [ref=e9]
- paragraph [ref=e10]: 企业管理系统
- generic [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- text: "*"
- generic [ref=e14]: 机构码
- textbox [active] [ref=e23]
- generic [ref=e24]:
- generic [ref=e25]:
- text: "*"
- generic [ref=e26]: 账号
- textbox [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: 密码
- generic [ref=e40]:
- textbox [ref=e45]
- button "显示密码" [ref=e46] [cursor=pointer]
- button "登录" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: © 2026 FMS · 企业管理系统
- complementary "FPS 性能监控" [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]: FPS
- strong [ref=e55]: "--"
- button "重置 FPS 统计" [ref=e56] [cursor=pointer]
- generic [ref=e61]:
- generic [ref=e62]: avg --
- generic [ref=e63]: min --
- status:
- generic:
- generic: FMS
- generic: 数字化管理系统
- generic: 正在加载,请稍候…
+37
View File
@@ -0,0 +1,37 @@
- generic [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- heading "登录 FMS" [level=1] [ref=e9]
- paragraph [ref=e10]: 企业管理系统
- generic [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- text: "*"
- generic [ref=e14]: 机构码
- textbox [active] [ref=e23]
- generic [ref=e24]:
- generic [ref=e25]:
- text: "*"
- generic [ref=e26]: 账号
- textbox [ref=e34]
- generic [ref=e35]:
- generic [ref=e36]: 密码
- generic [ref=e40]:
- textbox [ref=e45]
- button "显示密码" [ref=e46] [cursor=pointer]
- button "登录" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]: © 2026 FMS · 企业管理系统
- complementary "FPS 性能监控" [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]: FPS
- strong [ref=e55]: "--"
- button "重置 FPS 统计" [ref=e56] [cursor=pointer]
- generic [ref=e61]:
- generic [ref=e62]: avg --
- generic [ref=e63]: min --
- status:
- generic:
- generic: FMS
- generic: 数字化管理系统
- generic: 正在加载,请稍候…
@@ -24,6 +24,8 @@ const props = defineProps({
},
/** 单项 label 宽度覆盖(仅横向布局生效) */
labelWidth: { type: [String, Number], default: undefined },
/** 网格布局下跨列数(不超过 Form.columns,默认 1) */
span: { type: Number, default: 1 },
/** 外部手动指定校验状态:'' | 'error' */
validateStatus: {
type: String,
@@ -81,6 +83,14 @@ const labelStyle = computed(() => {
return { width: labelWidth.value }
})
// 网格布局(Form columns > 1)下跨列:span 超过列数时取整行
const itemStyle = computed(() => {
const columns = form?.columns?.value ?? 1
if (columns <= 1) return undefined
const span = Math.min(Math.max(Number(props.span) || 1, 1), columns)
return span > 1 ? { gridColumn: `span ${span}` } : undefined
})
/* ---------- 校验 ---------- */
async function runValidation() {
const nextErrors = await validateRules({
@@ -146,7 +156,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootEl" :class="rootClasses" @focusout="onRootFocusOut">
<div ref="rootEl" :class="rootClasses" :style="itemStyle" @focusout="onRootFocusOut">
<label
v-if="label !== '' || $slots.label"
class="fms-form-item-label"
@@ -21,6 +21,10 @@ const props = defineProps({
},
/** label 宽度(仅横向布局生效;默认使用 --fms-form-label-width) */
labelWidth: { type: [String, Number], default: undefined },
/** 每行字段列数(>1 时启用网格布局,字段按顺序从左到右自动换行) */
columns: { type: Number, default: 1 },
/** 表单最大宽度(Number 按 px 处理;不传则不限制) */
maxWidth: { type: [String, Number], default: undefined },
/** 必填红星标记 */
requiredMark: { type: Boolean, default: true },
/** 校验时机:change(值变化即校验)| blur(失焦时校验) */
@@ -33,6 +37,18 @@ const props = defineProps({
const emit = defineEmits(['submit', 'submitFailed', 'reset'])
/* ---------- 网格布局 ---------- */
const formStyle = computed(() => {
const style = {}
if (props.columns > 1) {
style.gridTemplateColumns = `repeat(${props.columns}, minmax(0, 1fr))`
}
if (props.maxWidth != null) {
style.maxWidth = typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
}
return style
})
/* ---------- 字段注册表 ---------- */
// name -> { validate, clearValidation },由 FormItem 注册 / 注销
const fieldMap = new Map()
@@ -83,15 +99,25 @@ provide('fms-form', {
layout: computed(() => props.layout),
labelAlign: computed(() => props.labelAlign),
labelWidth: computed(() => props.labelWidth),
columns: computed(() => props.columns),
requiredMark: computed(() => props.requiredMark),
validateTrigger: computed(() => props.validateTrigger),
registerField,
unregisterField,
})
// 供父级强制校验(如保存前):返回带错误的字段列表 [{ name, errors }]
defineExpose({ validateAll })
</script>
<template>
<form class="fms-form" :class="`fms-form-${layout}`" @submit="onSubmit" @reset="onReset">
<form
class="fms-form"
:class="[`fms-form-${layout}`, { 'fms-form-grid': columns > 1 }]"
:style="formStyle"
@submit="onSubmit"
@reset="onReset"
>
<slot />
</form>
</template>
@@ -22,6 +22,19 @@
align-items: stretch;
}
/* ---------- 多列网格布局(Form columns > 1) ----------
字段按顺序从左到右自动换行;行/列间距由 grid gap 统一控制,
覆盖 .fms-form-item 的 margin-bottom 避免重复加距 */
.fms-form-grid {
display: grid;
gap: 12px 16px;
.fms-form-item {
margin-bottom: 0;
min-width: 0;
}
}
/* ---------- label ---------- */
.fms-form-item-label {
flex-shrink: 0;
@@ -87,6 +100,14 @@
color: var(--fms-danger);
}
/* 校验失败:控件边框标红(覆盖常见控件的边框类) */
.fms-form-item-error {
:deep(.input-wrapper),
:deep(.select-trigger) {
border-color: var(--fms-danger);
}
}
/* 错误文字进场仅做透明度过渡,低成本且支持减弱动效 */
.fms-form-item-help-error {
transition: opacity 150ms ease;
@@ -42,11 +42,22 @@ const DEFAULT_MESSAGES = {
max: (label, rule) => (label ? `${label}长度不能超过 ${rule.max}` : `长度不能超过 ${rule.max}`),
}
/**
* 解析规则错误消息:message 支持字符串或函数(函数在每次校验时求值,
* 可用于随字段状态动态切换文案)。未配置 message 时回退到默认文案。
*/
function resolveMessage(rule, label, kind, arg) {
const raw = typeof rule.message === 'function' ? rule.message() : rule.message
if (raw != null && raw !== '') return raw
const fallback = DEFAULT_MESSAGES[kind]
return fallback ? fallback(label, arg) : label ? `${label}校验未通过` : '校验未通过'
}
/** 执行规则中的静态约束(required / type / pattern / len / min / max) */
function validateStatic(rule, { label, value }) {
// 必填失败:空值直接返回,其余约束不再执行
if (rule.required && isEmptyValue(value)) {
return [rule.message || DEFAULT_MESSAGES.required(label)]
return [resolveMessage(rule, label, 'required')]
}
// 非必填约束只在有值时执行,避免"必填"与"格式"重复报错
@@ -55,23 +66,23 @@ function validateStatic(rule, { label, value }) {
const errors = []
if (rule.type && !TYPE_VALIDATORS[rule.type]?.(value)) {
errors.push(rule.message || DEFAULT_MESSAGES.type(label))
errors.push(resolveMessage(rule, label, 'type'))
}
if (rule.pattern && !rule.pattern.test(value)) {
errors.push(rule.message || DEFAULT_MESSAGES.pattern(label))
errors.push(resolveMessage(rule, label, 'pattern'))
}
const length = measure(value)
if (length !== undefined) {
if (rule.len !== undefined && length !== rule.len) {
errors.push(rule.message || DEFAULT_MESSAGES.len(label, rule))
errors.push(resolveMessage(rule, label, 'len', rule))
}
if (rule.min !== undefined && length < rule.min) {
errors.push(rule.message || DEFAULT_MESSAGES.min(label, rule))
errors.push(resolveMessage(rule, label, 'min', rule))
}
if (rule.max !== undefined && length > rule.max) {
errors.push(rule.message || DEFAULT_MESSAGES.max(label, rule))
errors.push(resolveMessage(rule, label, 'max', rule))
}
}
@@ -85,7 +96,7 @@ function validateStatic(rule, { label, value }) {
* - 返回非空字符串 → 该字符串作为错误消息
*/
function runValidator(rule, { label, value }, staticErrors) {
const fallbackError = rule.message || (label ? `${label}校验未通过` : '校验未通过')
const fallbackError = resolveMessage(rule, label, 'validator')
return Promise.resolve()
.then(() => rule.validator(value, rule))
.then((verdict) => {
@@ -1,5 +1,5 @@
<script setup>
import { computed, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from 'vue'
import { computed, Fragment, isVNode, nextTick, reactive, ref, useId, useSlots, watch } from 'vue'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
@@ -80,8 +80,10 @@ const slotPanes = computed(() => {
if (!isTabPane(node)) continue
const paneProps = node.props ?? {}
const { content, label } = extractChildren(node.children)
// key 解析顺序:tabKey prop > vnode.key > 出现顺序索引
const rawKey = paneProps.tabKey != null ? paneProps.tabKey : node.key != null ? node.key : index
// 模板编译的 vnode.props 保留模板中原始属性名(kebab-case),h() 调用为 camelCase,两者都兼容
const tabKey = paneProps.tabKey != null ? paneProps.tabKey : paneProps['tab-key']
// 不能用 node.key 兜底:编译器会给带 v-if 的兄弟 TabPane 注入递增 key,导致多个 pane key 相同
const rawKey = tabKey != null ? tabKey : index
panes.push({
key: String(rawKey),
rawKey,
@@ -243,3 +243,70 @@
transition: none;
}
}
/* ============================================================
* 右键菜单(fixed 定位,挂载到 body)
* ============================================================ */
.tree-context-menu {
position: fixed;
z-index: 3000;
min-width: 140px;
padding: 4px;
background: var(--fms-card);
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.tree-context-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 10px;
border: none;
background: transparent;
color: var(--fms-text);
font-size: 13px;
line-height: 1.5;
text-align: left;
cursor: pointer;
&:hover:not(:disabled) {
background: var(--fms-secondary-hover);
}
&.is-danger {
color: #f53f3f;
}
&.is-disabled {
color: var(--fms-disabled-text);
cursor: not-allowed;
}
}
.tree-context-menu__icon {
flex: none;
}
.tree-context-menu__divider {
height: 1px;
margin: 4px 8px;
background: var(--fms-border);
}
/* 淡入淡出动画 */
.tree-context-fade-enter-active {
transition: opacity 0.12s ease;
}
.tree-context-fade-leave-active {
transition: opacity 0.08s ease;
}
.tree-context-fade-enter-from,
.tree-context-fade-leave-to {
opacity: 0;
}
@@ -125,6 +125,11 @@ function onDragLeave(e) {
function onDrop(e) {
ctx.onDrop(props.node, e)
}
// 节点右键:交由容器统一处理(阻止冒泡,避免树空白区右键逻辑重复触发)
function onContextMenu(e) {
if (disabled.value) return
ctx.onNodeContextMenu(props.node, e)
}
</script>
<template>
@@ -143,6 +148,7 @@ function onDrop(e) {
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@contextmenu.stop="onContextMenu"
>
<span v-if="ctx.props.draggable" class="tree-node-grip" :aria-hidden="true"
><GripVertical
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, provide, watch, useSlots, onMounted } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useSlots, watch } from 'vue'
import TreeNode from './tree-node.vue'
import {
resolveFieldNames,
@@ -37,6 +37,12 @@ const props = defineProps({
defaultExpandAll: { type: Boolean, default: false },
defaultExpandParent: { type: Boolean, default: true },
autoExpandParent: { type: Boolean, default: false },
/**
* 右键菜单项数据源:静态数组,或函数 (node, key) => items(node 为 null 表示空白处右键)。
* items: [{ key, label, icon?, disabled?, danger?, divider? }]
* 不传则仅发 contextmenu 事件,由使用方自行渲染菜单。
*/
contextMenu: { type: [Array, Function], default: undefined },
// 受控键
expandedKeys: { type: Array, default: undefined },
selectedKeys: { type: Array, default: undefined },
@@ -63,6 +69,10 @@ const emit = defineEmits([
'update:selectedKeys',
'update:checkedKeys',
'update:loadedKeys',
// 节点右键:{ node, key, event };node 为 null 表示在树空白区域右键
'contextmenu',
// 右键菜单项点击:{ item, node, key, event }
'contextMenuClick',
])
/* =========================================================================
@@ -420,6 +430,91 @@ function onFocus() {
}
}
/* ---------- 右键菜单 ---------- */
const contextState = ref(null) // { items, x, y, node, key }
const contextMenuEl = ref(null)
function resolveContextItems(node, key) {
if (typeof props.contextMenu === 'function') return props.contextMenu(node, key)
return props.contextMenu || []
}
// 打开菜单:先以鼠标位置定位,渲染后按菜单实际尺寸做视口边缘翻转
function openContextMenu(node, key, event) {
const items = resolveContextItems(node, key)
if (!items || !items.length) return
contextState.value = { items, x: event.clientX, y: event.clientY, node, key }
nextTick(() => {
const el = contextMenuEl.value
if (!el || !contextState.value) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let { x, y } = contextState.value
if (x + rect.width > vw - 8) x = Math.max(8, x - rect.width)
if (y + rect.height > vh - 8) y = Math.max(8, y - rect.height)
contextState.value.x = x
contextState.value.y = y
})
}
function hideContextMenu() {
contextState.value = null
}
// 节点右键(tree-node 调用,已 stopPropagation)
function onNodeContextMenu(node, event) {
event.preventDefault()
emit('contextmenu', { node, key: getNodeKey(node, fn.value), event })
openContextMenu(node, getNodeKey(node, fn.value), event)
}
// 树空白区域右键:只在事件目标是树容器自身(或树内非节点区域)时处理,
// 节点行上的右键已 stopPropagation,不会走到这里
function onRootContextMenu(event) {
event.preventDefault()
emit('contextmenu', { node: null, key: null, event })
openContextMenu(null, null, event)
}
function onMenuItemClick(item, event) {
if (item.disabled) return
const state = contextState.value
emit('contextMenuClick', {
item,
node: state?.node ?? null,
key: state?.key ?? null,
event,
})
hideContextMenu()
}
// 关闭监听:点击菜单外任意处、Escape、滚动、窗口尺寸变化
function onDocMouseDown(e) {
if (!contextState.value) return
const el = contextMenuEl.value
if (el && el.contains(e.target)) return
hideContextMenu()
}
function onDocKeydown(e) {
if (e.key === 'Escape') hideContextMenu()
}
function onDocScroll() {
hideContextMenu()
}
onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown, true)
document.addEventListener('keydown', onDocKeydown, true)
window.addEventListener('scroll', onDocScroll, true)
window.addEventListener('resize', onDocScroll)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMouseDown, true)
document.removeEventListener('keydown', onDocKeydown, true)
window.removeEventListener('scroll', onDocScroll, true)
window.removeEventListener('resize', onDocScroll)
})
/* =========================================================================
* 向递归节点下发上下文(状态集合 + 交互处理函数)
* ========================================================================= */
@@ -447,6 +542,7 @@ provide('tree', {
onDragLeave,
onDrop,
onDragEnd,
onNodeContextMenu,
registerNode,
unregisterNode,
titleSlot: slots.title,
@@ -462,6 +558,7 @@ provide('tree', {
@keydown="onKeydown"
@focusin="onFocus"
@dragleave="onTreeDragLeave"
@contextmenu="onRootContextMenu"
>
<template v-for="k in flat.rootKeys" :key="k">
<TreeNode :node="flat.nodeMap.get(k)" :level="0" />
@@ -470,6 +567,36 @@ provide('tree', {
<slot name="empty">暂无数据</slot>
</div>
</div>
<!-- 右键菜单(挂载到 body,fixed 定位;使用方也可不传 contextMenu 自行渲染) -->
<Teleport to="body">
<Transition name="tree-context-fade">
<div
v-if="contextState"
ref="contextMenuEl"
class="tree-context-menu"
:style="{ left: contextState.x + 'px', top: contextState.y + 'px' }"
role="menu"
@mousedown.stop
>
<template v-for="(item, index) in contextState.items" :key="item.key || index">
<div v-if="item.divider" class="tree-context-menu__divider" role="separator"></div>
<button
v-else
type="button"
class="tree-context-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:disabled="item.disabled"
@click="(event) => onMenuItemClick(item, event)"
>
<component :is="item.icon" v-if="item.icon" :size="14" class="tree-context-menu__icon" />
<span>{{ item.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
+17 -6
View File
@@ -192,11 +192,15 @@ function onSplitterResize(sizes) {
transition: none;
}
/* 主区面板:四周留 8px 间距,左侧 0。
* 左侧空隙改由侧栏内容自身的右 padding(8px)承担,与右侧 8px 对称;
* 左 0 后卡片阴影会外扩到面板边界外,须将主区面板 overflow 改为 visible 才不被裁。
/* 主区面板:四周留 8px 间距(含左侧),与侧栏拉开标准间隙。
* 折叠态(窄侧栏 48px)左侧归 0,白卡贴侧栏右缘,避免过窄时两侧都留间隙。
* 卡片阴影会外扩到面板边界外,须将主区面板 overflow 改为 visible 才不被裁。
* 卡片自身 overflow: hidden + .fms-content 内部滚动,内容不会真正外溢,改为 visible 安全 */
.fms-main-panel {
padding: 8px;
}
.fms-layout.is-collapsed .fms-main-panel {
padding: 8px 8px 8px 0;
}
@@ -229,18 +233,25 @@ function onSplitterResize(sizes) {
box-shadow: var(--fms-shadow);
}
/* 内容区:白卡内滚动区,顶部由 .fms-tabs 的底边线分隔 */
/* 内容区:白卡内滚动区,顶部由 .fms-tabs 的底边线分隔。
* flex column:让页面根可用 flex:1 撑满可视高度(百分比高度链在滚动容器内不可靠) */
.fms-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
padding: 12px 16px 16px;
}
/* 页面过渡外层包裹:稳定的 DOM 根,供 transition 的 out-in 模式直接管理,
* 避免 keep-alive 直接作 Transition 子节点时在 patch 阶段访问 null.parentNode 报错 */
* 避免 keep-alive 直接作 Transition 子节点时在 patch 阶段访问 null.parentNode 报错。
* flex:1 撑满内容区:低内容页面占满剩余高度,高内容页面自然增高由 .fms-content 滚动 */
.fms-page-wrap {
min-height: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ---- 页面过渡 ---- */
@@ -8,6 +8,7 @@ import {
Component,
GalleryVerticalEnd,
LayoutGrid,
Settings,
} from '@lucide/vue'
import { useAppStore } from '@/stores/app'
import { usePermissionStore } from '@/stores/permissions'
@@ -49,6 +50,15 @@ const navMain = computed(() => {
],
})
// 系统管理分组(先静态配置,动态菜单/权限后续接入)
items.push({
title: '系统管理',
icon: Settings,
items: [
{ title: '模块管理', url: '/system/module-management' },
],
})
const menuModules = permissionStore.modules.filter(
(m) => m.b_canmenu === 1 && m.b_canuse === 1 && ['directory', 'page'].includes(m.b_module_type),
)
+7
View File
@@ -87,6 +87,13 @@ const router = createRouter({
component: () => import('@/views/detail/DetailPage.vue'),
meta: { title: '详情', cacheByKey: true },
},
// 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入)
{
path: '/system/module-management',
name: 'system-module-management',
component: () => import('@/views/system/module-management/index.vue'),
meta: { title: '模块管理', componentName: 'ModuleManagement' },
},
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
],
},
+57
View File
@@ -0,0 +1,57 @@
import { nextIdApi } from '@/services/api'
export function cloneData(value) {
return JSON.parse(JSON.stringify(value))
}
export function diffRows(current, previous) {
const previousById = new Map(previous.map((item) => [String(item.b_id), item]))
const currentById = new Map(current.map((item) => [String(item.b_id), item]))
return {
inserts: current.filter((item) => !previousById.has(String(item.b_id))),
updates: current.filter((item) => {
const old = previousById.get(String(item.b_id))
return old && JSON.stringify(item) !== JSON.stringify(old)
}),
deletes: previous
.filter((item) => !currentById.has(String(item.b_id)))
.map((item) => ({ b_id: item.b_id })),
}
}
export async function allocateTemporaryIds(rowGroups) {
const temporaryIds = [
...new Set(
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ''))
.filter((id) => /^-\d+$/.test(id)),
),
]
if (!temporaryIds.length) return new Map()
const res = await nextIdApi(temporaryIds.length)
const ids = Array.isArray(res.data) ? res.data : [res.data]
const mapping = new Map(temporaryIds.map((id, index) => [id, String(ids[index])]))
rowGroups.flat().forEach((row) => {
const id = mapping.get(String(row?.b_id))
if (id) row.b_id = id
})
return mapping
}
export function remapForeignKeys(rows, fields, mapping) {
rows.forEach((row) => {
fields.forEach((field) => {
const value = mapping.get(String(row[field]))
if (value) row[field] = value
})
})
}
export function tableChange(table, changes) {
return { table, key_field: 'b_id', ...changes }
}
export function hasChanges(request) {
return request.inserts?.length || request.updates?.length || request.deletes?.length
}
+120
View File
@@ -0,0 +1,120 @@
export function validateModuleCode(code) {
const value = String(code ?? '').trim()
if (!value) return '编码不能为空'
if (value.length < 2 || value.length > 50) return '编码长度需为 2-50 个字符'
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
return '编码须以小写字母开头,且只能包含小写字母、数字和下划线'
}
return ''
}
export function getI18nNamespace(key) {
return String(key || '').split('.', 1)[0] || 'common'
}
export function isValidI18nKey(key) {
return /^[A-Za-z][A-Za-z0-9_.:-]{1,149}$/.test(String(key || ''))
}
export function buildModuleItemI18nKey(prefix, moduleCode, itemCode) {
const normalizedModuleCode = String(moduleCode || '').trim()
const normalizedItemCode = String(itemCode || '').trim()
return normalizedModuleCode && normalizedItemCode
? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}`
: null
}
export function buildModuleI18nKey(moduleType, moduleCode) {
const normalizedModuleCode = String(moduleCode || '').trim()
if (!normalizedModuleCode) return null
const prefix = moduleType === 'data' || moduleType === 'query' ? 'module' : 'menu'
return `${prefix}.${normalizedModuleCode}`
}
export function syncModuleItemI18nKeys(rows, prefix, moduleCode, codeField) {
let changed = false
for (const row of rows || []) {
const key = buildModuleItemI18nKey(prefix, moduleCode, row?.[codeField])
if (row.b_i18n === key) continue
row.b_i18n = key
changed = true
}
return changed
}
export function groupI18nRows(rows) {
const groups = {}
rows.forEach((item) => {
const key = item.b_key
groups[key] ||= { key, values: {}, records: {} }
groups[key].values[item.b_locale] = item.b_value
groups[key].records[item.b_locale] = item
})
return groups
}
// 将同步字段生成的默认翻译条目并入透视行数组:
// - 已存在同 b_key 的行则补全其空语言值(不覆盖用户已填内容);
// - 否则新增一行;managedId 用于关联字段,使重命名字段时复用同一行(改名而非新增)。
// 返回新的数组,不修改入参。
export function mergeI18nDefaults(rows, entries) {
const next = [...rows]
const byKey = new Map(next.map((r) => [String(r.b_key), r]))
const managedIds = new Set(
next
.map((r) => r._managedId)
.filter(Boolean)
.map(String),
)
for (const e of entries || []) {
if (!e.key) continue
const existing = byKey.get(String(e.key))
if (existing) {
// 已存在同键行:仅补全空值,保留用户已配置的翻译
if (existing[e.locale] == null || existing[e.locale] === '') existing[e.locale] = e.value
continue
}
// 同键不存在,但该受管键已由其它行代表时不再新建,避免重复键
if (e.managedId && managedIds.has(String(e.managedId))) continue
const row = {
_id: 'i18n_' + Math.random().toString(36).slice(2, 10),
b_key: e.key,
_localeIds: {},
_managedId: e.managedId ? String(e.managedId) : undefined,
_syncInjected: e.managedId ? true : undefined,
[e.locale]: e.value,
}
next.push(row)
byKey.set(String(e.key), row)
if (e.managedId) managedIds.add(String(e.managedId))
}
return next
}
// 合并同 b_key 的重复透视行,保证一个键仅一行。
// 返回新的数组,不修改入参。
export function consolidatePivotRows(rows) {
const byKey = new Map()
const merged = []
for (const row of rows) {
const key = String(row.b_key || '')
const hit = byKey.get(key)
if (hit) {
for (const [k, v] of Object.entries(row)) {
if (k === '_id' || k === '_localeIds') continue
// 仅当目标为空(null/undefined/空串)且来源非空时才补全,保留首个非空值
if ((hit[k] == null || hit[k] === '') && v != null) hit[k] = v
}
Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {})
if (row._managedId && !hit._managedId) hit._managedId = row._managedId
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected
if (row._defaultValue != null && hit._defaultValue == null)
hit._defaultValue = row._defaultValue
if (row._sortType && !hit._sortType) hit._sortType = row._sortType
} else {
byKey.set(key, row)
merged.push(row)
}
}
return merged
}
+12
View File
@@ -0,0 +1,12 @@
// 生成本地临时 ID,用于尚未保存到后端的子表新增行。
// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。
let sequence = 0
/**
* 生成会话内唯一的临时 ID(负数字符串)。
* @returns {string} 形如 "-1"、"-2" 的临时 ID。
*/
export function createTempId() {
sequence += 1
return String(-sequence)
}
@@ -0,0 +1,6 @@
// 前端默认语言列表(默认语言排第一)。
// 后续若引入语言类型表(s_i18n_type)再改为接口加载。
export const DEFAULT_LANGUAGES = [
{ code: 'zh-CN', name: '简体中文', default: true },
{ code: 'en-US', name: 'English', default: false },
]
@@ -0,0 +1,245 @@
<script setup>
/**
* 字段分组管理弹窗:管理 s_field_group 树形分组。
* 独立保存到后端,保存后 emit("saved", groups, idMapping),父级用 idMapping
* 重映射列表/编辑配置中的 b_group_id(临时 ID → 雪花 ID)。
*/
import { computed, ref, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Tree from '@/components/ui/tree/tree.vue'
import { Plus, Trash2, FolderPlus } from '@lucide/vue'
import { buildTree } from '@/utils/tree'
import { createTempId } from '@/utils/tempId'
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from '@/utils/dataChanges'
import { saveObjectApi } from '@/services/api'
const props = defineProps({
open: { type: Boolean, default: false },
moduleId: { type: String, required: true },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:open', 'saved'])
const localGroups = ref([])
const selectedGroupId = ref(null)
const saving = ref(false)
watch(
() => props.groups,
(val) => {
localGroups.value = cloneData(val || [])
},
{ immediate: true },
)
const treeData = computed(() => buildTree(localGroups.value))
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === String(selectedGroupId.value)),
)
function onTreeSelect(keys) {
selectedGroupId.value = keys.length ? String(keys[0]) : null
}
function addGroup(parentId) {
const newGroup = {
b_id: createTempId(),
b_module_id: props.moduleId,
b_parent_id: parentId || null,
b_title: '',
b_xh: (localGroups.value.length + 1) * 10,
b_canuse: 1,
}
localGroups.value.push(newGroup)
selectedGroupId.value = String(newGroup.b_id)
}
function deleteGroupById(id) {
if (!id) return
const hasChildren = localGroups.value.some(
(g) => String(g.b_parent_id) === String(id) && String(g.b_id) !== String(id),
)
if (hasChildren) {
Message.warning('该分组下还有子分组,请先删除子分组')
return
}
if (props.referencedGroupIds.some((gid) => String(gid) === String(id))) {
Message.warning('该分组仍被列表或编辑配置引用,请先清空引用')
return
}
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== String(id))
if (String(selectedGroupId.value) === String(id)) selectedGroupId.value = null
}
async function save() {
if (saving.value) return
const changes = diffRows(localGroups.value, cloneData(props.groups || []))
const req = tableChange('s_field_group', changes)
if (!hasChanges(req)) {
Message.info('没有需要保存的变更')
emit('update:open', false)
emit('saved', localGroups.value, new Map())
return
}
saving.value = true
try {
const mapping = await allocateTemporaryIds([req.inserts || [], req.updates || []])
await saveObjectApi([req])
Message.success('分组已保存')
emit('update:open', false)
emit('saved', localGroups.value, mapping)
} catch (err) {
Message.error('保存失败:' + (err?.message || err))
} finally {
saving.value = false
}
}
</script>
<template>
<Modal
:open="open"
title="分组管理"
width="640"
:confirm-loading="saving"
:resizable="false"
@update:open="(v) => emit('update:open', v)"
@ok="save"
>
<div class="mm-groups">
<aside class="mm-groups__tree">
<div class="mm-groups__toolbar">
<Button type="outline" @click="addGroup(null)">
<Plus :size="14" /> 新增根分组
</Button>
<Button
type="outline"
:disabled="!selectedGroupId"
@click="addGroup(selectedGroupId)"
>
<FolderPlus :size="14" /> 新增子分组
</Button>
</div>
<div class="mm-groups__scroll">
<Tree
:tree-data="treeData"
:field-names="{ key: 'b_id', title: 'b_title', children: 'children' }"
default-expand-all
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
@select="onTreeSelect"
/>
</div>
</aside>
<section class="mm-groups__detail">
<template v-if="selectedGroup">
<div class="mm-groups__form">
<div class="mm-form-row">
<label>分组标题</label>
<Input v-model="selectedGroup.b_title" placeholder="分组标题" />
</div>
<div class="mm-form-row">
<label>排序号</label>
<Input v-model="selectedGroup.b_xh" type="number" />
</div>
<div class="mm-form-row">
<label>启用</label>
<Switch
:model-value="Number(selectedGroup.b_canuse) === 1"
@update:model-value="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
</div>
</div>
<div class="mm-groups__danger">
<Button type="danger" @click="deleteGroupById(selectedGroup.b_id)">
<Trash2 :size="14" /> 删除该分组
</Button>
</div>
</template>
<div v-else class="mm-groups__empty">请选择左侧分组节点</div>
</section>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.mm-groups {
display: flex;
gap: 8px;
height: 420px;
min-height: 0;
&__tree {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 280px;
min-height: 0;
padding: 8px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary-hover);
}
&__toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 8px;
}
&__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
&__detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
}
&__form {
display: flex;
flex-direction: column;
gap: 12px;
}
&__danger {
margin-top: 20px;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--fms-text-secondary);
font-size: 13px;
}
}
.mm-form-row {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
label {
font-size: 13px;
color: var(--fms-text-secondary);
text-align: right;
}
}
</style>
@@ -0,0 +1,150 @@
<script setup>
/**
* 快速设置多语言弹窗:按 key 加载 s_i18n 各语言值,保存后 emit("saved", key)。
* 调用方通过 ref 调用 open(key, defaultValue),defaultValue 用于默认语言的初始值。
* 语言列表暂用前端常量;后续若引入语言类型表(s_i18n_type)再改为接口加载。
*/
import { ref } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Input from '@/components/ui/input/input.vue'
import { loadDataApi, saveObjectApi } from '@/services/api'
import { DEFAULT_LANGUAGES } from '../common'
const emit = defineEmits(['saved'])
const visible = ref(false)
const saving = ref(false)
const i18nKey = ref('')
const entries = ref([])
async function open(key, defaultValue = '') {
if (!key) {
Message.warning('多语言键为空')
return
}
i18nKey.value = key
const escaped = key.replace(/'/g, "''")
try {
const res = await loadDataApi('s_i18n', `b_key = '${escaped}'`, 'b_locale ASC')
const rows = res.data || []
const byLocale = {}
for (const row of rows) {
byLocale[row.b_locale] = row
}
const defaultLocale = String(DEFAULT_LANGUAGES[0]?.code || '')
const values = []
for (const language of DEFAULT_LANGUAGES) {
const existing = byLocale[language.code]
let value = ''
if (existing) {
value = existing.b_value
} else if (language.code === defaultLocale) {
value = defaultValue
}
values.push({
b_id: existing ? existing.b_id : null,
b_locale: language.code,
b_name: language.name,
b_value: value,
})
}
entries.value = values
} catch {
entries.value = []
}
visible.value = true
}
async function save() {
if (!i18nKey.value) {
Message.warning('请填写多语言键')
return
}
saving.value = true
try {
const req = { table: 's_i18n', key_field: 'b_id', inserts: [], updates: [] }
for (const e of entries.value) {
const value = e.b_value == null ? '' : e.b_value
if (!e.b_id && !value) continue
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value, b_canuse: 1 }
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id })
else req.inserts.push(row)
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req])
}
Message.success('多语言已保存')
visible.value = false
emit('saved', i18nKey.value)
} catch (err) {
Message.error('保存失败:' + (err?.message || err))
} finally {
saving.value = false
}
}
defineExpose({ open })
</script>
<template>
<Modal
v-model:open="visible"
title="快速设置多语言"
:confirm-loading="saving"
:resizable="false"
@ok="save"
>
<div class="mm-i18n-quick">
<div class="mm-i18n-quick__key">
<label>多语言键</label>
<Input v-model="i18nKey" disabled />
</div>
<div class="mm-i18n-quick__rows">
<div v-for="(item, idx) in entries" :key="idx" class="mm-i18n-quick__row">
<span class="mm-i18n-quick__locale">{{ item.b_name }} ({{ item.b_locale }})</span>
<Input v-model="item.b_value" class="mm-i18n-quick__value" />
</div>
</div>
</div>
</Modal>
</template>
<style lang="scss" scoped>
.mm-i18n-quick {
display: flex;
flex-direction: column;
gap: 12px;
&__key {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
label {
font-size: 13px;
color: var(--fms-text-secondary);
text-align: right;
}
}
&__row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__locale {
flex: none;
width: 140px;
color: var(--fms-text-secondary);
font-size: 13px;
}
&__value {
flex: 1;
}
}
</style>
@@ -0,0 +1,60 @@
<script setup>
/**
* 图标选择器:基于 Select(内置搜索过滤),选项为常用 lucide 图标子集。
* 图标名首字母大写,如 "Settings"、"Users"。
*/
import { ref } from 'vue'
import Select from '@/components/ui/select/select.vue'
import { getIcon } from '@/utils/lucideIcons'
const props = defineProps({
modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const ICON_NAMES = [
'LayoutGrid', 'Home', 'Folder', 'FolderOpen', 'FolderTree', 'Boxes', 'Database',
'Table2', 'List', 'ListChecks', 'ClipboardList', 'FileText', 'FilePlus2', 'Files',
'Settings', 'Settings2', 'SlidersHorizontal', 'Cog', 'Wrench', 'Shield', 'ShieldCheck',
'Users', 'User', 'UserPlus', 'UserCog', 'Building2', 'Building', 'Contact',
'Briefcase', 'Banknote', 'ShoppingCart', 'Truck', 'Package', 'Box', 'Warehouse',
'CalendarDays', 'CalendarClock', 'Clock', 'Timer', 'History', 'AlarmClock',
'Search', 'Filter', 'Eye', 'EyeOff', 'Lock', 'Unlock', 'Key', 'KeyRound',
'Plus', 'Minus', 'Pencil', 'Trash2', 'Copy', 'Save', 'Upload', 'Download',
'RefreshCw', 'RotateCw', 'Undo2', 'Redo2', 'ExternalLink', 'Link', 'Share2',
'Printer', 'Mail', 'Phone', 'MapPin', 'Globe', 'Globe2', 'Languages', 'Translate',
'BookOpen', 'BookMarked', 'GraduationCap', 'Award', 'Star', 'Heart', 'Flag',
'Check', 'CheckCircle2', 'XCircle', 'AlertCircle', 'AlertTriangle', 'Info',
'ChevronRight', 'ChevronDown', 'ChevronUp', 'ChevronsUpDown', 'ArrowRight',
'ArrowLeft', 'ArrowUpDown', 'ArrowDownUp', 'MoveUp', 'MoveDown', 'GripVertical',
'BarChart3', 'PieChart', 'TrendingUp', 'TrendingDown', 'Activity', 'Gauge',
'CircleDot', 'Radio', 'Play', 'Pause', 'Video', 'Image', 'Camera', 'Palette',
'Music', 'MessageSquare', 'MessageCircle', 'Bell', 'BellRing', 'Inbox',
'Send', 'Paperclip', 'AttachFile', 'Cloud', 'CloudUpload', 'CloudDownload',
'Cpu', 'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet', 'Mouse',
'Network', 'Server', 'Plug', 'Wifi', 'Zap', 'Battery', 'Power', 'LogIn', 'LogOut',
]
const iconOptions = ref(
ICON_NAMES.map((name) => ({ label: name, value: name })),
)
function iconOf(name) {
return getIcon(name)
}
</script>
<template>
<Select
:model-value="modelValue"
:options="iconOptions"
:disabled="disabled"
filterable
clearable
placeholder="搜索图标名称"
/>
</template>
<style lang="scss" scoped></style>
@@ -0,0 +1,329 @@
<script setup>
/**
* 菜单配置面板(s_menu):基础信息 + 权限 + 多语言。
* 权限点 b_power_type=menu、b_resource=菜单编码;i18n 键 menu.{code} / menu.{code}.{action}。
*/
import { computed, ref, watch } from 'vue'
import { Languages, Info, Shield, Globe } from '@lucide/vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import Tabs from '@/components/ui/tabs/tabs.vue'
import TabPane from '@/components/ui/tabs/tab-pane.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import IconSelect from './IconSelect.vue'
import ModulePowersPanel from './ModulePowersPanel.vue'
import ModuleI18nPanel from './ModuleI18nPanel.vue'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
/** 当前菜单草稿对象(s_menu 行) */
menu: { type: Object, default: null },
parentName: { type: String, default: '' },
/** data/query 模块选项 [{label, value}](b_module_id 关联) */
moduleOptions: { type: Array, default: () => [] },
/** 菜单权限点草稿(s_power,b_power_type=menu) */
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'updatePowers', 'i18nSaved'])
const activeSection = ref('basic')
const formData = ref(null)
watch(
() => props.menu,
(val) => {
if (val === formData.value) return
formData.value = val ? { ...val } : null
},
{ immediate: true },
)
const menuTypeOptions = [
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据页面', value: 'data' },
{ label: '外链', value: 'external' },
]
const isDirectory = computed(() => formData.value?.b_menu_type === 'directory')
const isPage = computed(() => formData.value?.b_menu_type === 'page')
const isData = computed(() => formData.value?.b_menu_type === 'data')
const isExternal = computed(() => formData.value?.b_menu_type === 'external')
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => ({
b_code: [
{ required: true, message: '请填写菜单编码' },
{ validator: (value) => validateModuleCode(value) || true },
],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route: [{ required: true, message: '请填写页面路由' }],
b_module_id: [{ required: true, message: '请选择关联模块' }],
}))
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
// 先切回基本信息 tab,确保用户能看到标红字段
activeSection.value = 'basic'
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
function emitUpdate() {
if (formData.value) emit('update', formData.value)
}
function setField(field, value) {
if (!formData.value) return
formData.value[field] = value === '' ? null : value
emitUpdate()
}
// 编码变化时自动派生菜单多语言键
function onCodeInput(v) {
setField('b_code', v)
if (formData.value) {
formData.value.b_i18n = buildModuleI18nKey('menu', v)
emitUpdate()
}
}
const i18nModalRef = ref(null)
function openI18nModal() {
if (!formData.value || !i18nModalRef.value) return
const key = formData.value.b_i18n || buildModuleI18nKey('menu', formData.value.b_code)
i18nModalRef.value.open(key, formData.value.b_name || '')
}
function onI18nSaved(key) {
setField('b_i18n', key)
i18nPanelRef.value?.refreshKey?.(key)
emit('i18nSaved', key)
}
const i18nPanelRef = ref(null)
/** 供父组件保存时调用:按最终草稿态生成 s_i18n 请求 */
function saveI18n() {
const panel = i18nPanelRef.value
if (!panel) return null
if (typeof panel.reconcileManagedRows === 'function') panel.reconcileManagedRows(i18nItems.value)
return typeof panel.buildSaveRequest === 'function' ? panel.buildSaveRequest() : null
}
function markI18nSaved() {
i18nPanelRef.value?.markSaved?.()
}
defineExpose({ validate, saveI18n, markI18nSaved })
/* ---- i18n 受管键组装(菜单本身 + 权限点) ---- */
const i18nItems = computed(() => {
const items = []
if (formData.value) {
const key = formData.value.b_i18n || buildModuleI18nKey('menu', formData.value.b_code)
if (key) {
items.push({
key,
managed: true,
defaultValue: formData.value.b_name || '',
id: `menu:${key}`,
})
}
}
for (const power of props.powers || []) {
const key = String(power.b_i18n || '')
if (key) {
items.push({
key,
managed: true,
defaultValue: power.b_name || '',
id: `power:${key}`,
})
}
}
return items
})
</script>
<template>
<div class="mm-menu-detail" v-if="formData">
<Tabs v-model="activeSection" class="mm-menu-tabs">
<TabPane tab-key="basic">
<template #label><Info :size="14" /> 基本信息</template>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="菜单编码" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="菜单名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="formData.b_menu_type"
:options="menuTypeOptions"
:disabled="loading"
@update:model-value="(v) => setField('b_menu_type', v)"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<FormItem label="菜单图标" name="b_icon">
<IconSelect
:model-value="formData.b_icon || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(formData.b_canuse) === 1"
:disabled="loading"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal" label="页面路由" name="b_route" required>
<Input
:model-value="formData.b_route"
:disabled="loading"
placeholder="固定页面路由或外链地址"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<FormItem v-if="isData" label="关联模块" name="b_module_id" required>
<Select
:model-value="String(formData.b_module_id || '')"
:options="moduleOptions"
:disabled="loading"
placeholder="选择数据/查询模块"
clearable
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal || isData" label="访问权限编码" name="b_power_code">
<Input
:model-value="formData.b_power_code"
:disabled="loading"
placeholder="对应 s_power.b_code"
@update:model-value="(v) => setField('b_power_code', v)"
/>
</FormItem>
</Form>
</TabPane>
<TabPane tab-key="powers">
<template #label><Shield :size="14" /> 权限配置</template>
<ModulePowersPanel
:module-id="String(formData.b_id || '')"
:module-code="formData.b_code || ''"
:powers="powers"
:loading="loading"
power-type="menu"
:resource="formData.b_code || ''"
i18n-prefix="menu"
@update="(v) => emit('updatePowers', v)"
@i18n-saved="(key) => emit('i18nSaved', key)"
/>
</TabPane>
<TabPane tab-key="i18n">
<template #label><Globe :size="14" /> 多语言</template>
<ModuleI18nPanel
:module-id="String(formData.b_id || '')"
:i18n-items="i18nItems"
:loading="loading"
ref="i18nPanelRef"
/>
</TabPane>
</Tabs>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
</template>
<style lang="scss" scoped>
.mm-menu-detail {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-menu-tabs {
flex: 1;
min-height: 0;
:deep(.tabs-content) {
flex: 1;
min-height: 0;
overflow: auto;
}
}
.mm-basic__i18n {
display: flex;
align-items: center;
gap: 4px;
:deep(.input-wrapper) {
flex: 1;
min-width: 0;
}
}
.mm-basic__i18n-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
</style>
@@ -0,0 +1,298 @@
<script setup>
/**
* 自动编码面板(s_autocode):单条规则表单 + 实时预览。
* 保存时 b_current_period / b_current_value 由服务端维护,前端不提交。
*/
import { computed, ref, watch } from 'vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { createTempId } from '@/utils/tempId'
const props = defineProps({
moduleId: { type: String, required: true },
autoCode: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const DATE_FORMAT_OPTIONS = [
{ label: '不使用日期', value: '' },
{ label: 'yyyy', value: 'yyyy' },
{ label: 'yy', value: 'yy' },
{ label: 'yyyyMM', value: 'yyyyMM' },
{ label: 'yyMM', value: 'yyMM' },
{ label: 'yyyyMMdd', value: 'yyyyMMdd' },
{ label: 'yyyy-MM', value: 'yyyy-MM' },
{ label: 'yyyy-MM-dd', value: 'yyyy-MM-dd' },
{ label: 'yyyy/MM', value: 'yyyy/MM' },
{ label: 'yyyy/MM/dd', value: 'yyyy/MM/dd' },
]
const RESET_OPTIONS = [
{ label: '不重置', value: 'none' },
{ label: '每年', value: 'year' },
{ label: '每月', value: 'month' },
{ label: '每日', value: 'day' },
]
const localRow = ref(null)
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => {
const field = (props.fields || []).find(
(item) => String(item.b_id) === String(localRow.value?.b_field_id || ''),
)
return {
b_field_id: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (!value) return '请选择业务编号字段'
if (!field || Number(field.b_canuse) !== 1) return '请选择已启用的业务编号字段'
if (field.b_type !== 'input' && field.b_type !== 'textarea') {
return '业务编号字段必须使用文本控件类型'
}
return true
},
},
],
b_startvalue: [
{
type: 'integer',
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (value === '' || value == null) return '请填写起始值'
if (Number(value) < 0) return '起始值必须是大于或等于 0 的整数'
return true
},
message: '起始值必须是大于或等于 0 的整数',
},
],
b_resettype: [{ required: true, message: '请选择重置周期' }],
b_seqwidth: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
const width = Number(value)
if (value === '' || value == null) return '请填写流水号位数'
if (!Number.isInteger(width) || width < 1 || width > 18) {
return '流水号位数必须是 1 到 18 之间的整数'
}
return true
},
},
],
}
})
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
defineExpose({ validate })
const fieldOptions = computed(() => {
const options = []
for (const field of props.fields || []) {
if (Number(field.b_canuse) !== 1) continue
if (field.b_field === 'b_id') continue
if (field.b_type !== 'input' && field.b_type !== 'textarea') continue
options.push({
label: `${field.b_name || field.b_field} (${field.b_field})`,
value: String(field.b_id),
})
}
return options
})
function createDraft(moduleId, fields, enabled = false) {
const textFields = (fields || []).filter(
(item) =>
Number(item.b_canuse) === 1 &&
item.b_field !== 'b_id' &&
(item.b_type === 'input' || item.b_type === 'textarea'),
)
return {
b_id: createTempId(),
b_module_id: String(moduleId),
b_field_id: textFields[0]?.b_id != null ? String(textFields[0].b_id) : null,
b_prefix: '',
b_dateformat: 'yyyyMM',
b_separator: '-',
b_seqwidth: 5,
b_resettype: 'month',
b_startvalue: 1,
b_currentperiod: null,
b_currentvalue: 0,
b_canuse: enabled ? 1 : 0,
}
}
watch(
() => props.autoCode,
(rows) => {
const row = rows && rows.length ? rows[0] : null
localRow.value = row ? { ...row } : createDraft(props.moduleId, props.fields, false)
},
{ immediate: true },
)
function notifyUpdate() {
if (localRow.value) emit('update', [{ ...localRow.value }])
}
function toggleEnabled(checked) {
if (!localRow.value) return
localRow.value.b_canuse = checked ? 1 : 0
notifyUpdate()
}
function setRowValue(field, value) {
if (!localRow.value) return
localRow.value[field] = value
notifyUpdate()
}
function pad(value) {
return String(value).padStart(2, '0')
}
function formatDate(date, format) {
const yyyy = String(date.getFullYear())
const yy = yyyy.slice(-2)
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
return String(format || '')
.replaceAll('yyyy', yyyy)
.replaceAll('yy', yy)
.replaceAll('MM', MM)
.replaceAll('dd', dd)
}
function currentPeriod(date, resetType) {
const yyyy = String(date.getFullYear())
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
if (resetType === 'none') return '*'
if (resetType === 'year') return yyyy
if (resetType === 'day') return yyyy + MM + dd
return yyyy + MM
}
const preview = computed(() => {
if (!localRow.value) return ''
const today = new Date()
const startValue = Number(localRow.value.b_startvalue) || 1
const storedValue = Number(localRow.value.b_currentvalue) || 0
const activePeriod = currentPeriod(today, localRow.value.b_resettype)
const sequence =
localRow.value.b_currentperiod === activePeriod ? storedValue + 1 : startValue
const parts = []
if (localRow.value.b_prefix) parts.push(localRow.value.b_prefix)
const dateText = formatDate(today, localRow.value.b_dateformat)
if (dateText) parts.push(dateText)
parts.push(String(sequence).padStart(Number(localRow.value.b_seqwidth) || 1, '0'))
return parts.join(localRow.value.b_separator || '')
})
</script>
<template>
<div v-if="localRow" class="mm-auto-code">
<Form ref="formRef" :model="localRow" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="启用自动编码" name="b_canuse">
<Switch
:model-value="autoCodeEnabled"
:disabled="loading"
@update:model-value="toggleEnabled"
/>
</FormItem>
<FormItem label="业务编号字段" name="b_field_id">
<Select
:model-value="String(localRow.b_field_id || '')"
:options="fieldOptions"
:disabled="loading || !autoCodeEnabled"
placeholder="选择文本字段"
@update:model-value="(v) => setRowValue('b_field_id', v)"
/>
</FormItem>
<FormItem label="固定前缀" name="b_prefix">
<Input
:model-value="localRow.b_prefix"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_prefix', v)"
/>
</FormItem>
<FormItem label="日期格式" name="b_dateformat">
<Select
:model-value="localRow.b_dateformat"
:options="DATE_FORMAT_OPTIONS"
:disabled="loading || !autoCodeEnabled"
allow-free-input
@update:model-value="(v) => setRowValue('b_dateformat', v)"
/>
</FormItem>
<FormItem label="分隔符" name="b_separator">
<Input
:model-value="localRow.b_separator"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_separator', v)"
/>
</FormItem>
<FormItem label="流水号位数" name="b_seqwidth">
<Input
:model-value="localRow.b_seqwidth"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_seqwidth', v)"
/>
</FormItem>
<FormItem label="起始值" name="b_startvalue">
<Input
:model-value="localRow.b_startvalue"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_startvalue', v)"
/>
</FormItem>
<FormItem label="重置周期" name="b_resettype">
<Select
:model-value="localRow.b_resettype"
:options="RESET_OPTIONS"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(v) => setRowValue('b_resettype', v)"
/>
</FormItem>
<FormItem label="编号预览" name="b_preview" :span="2">
<div class="mm-auto-code__preview">{{ preview }}</div>
</FormItem>
</Form>
</div>
</template>
<style lang="scss" scoped>
.mm-auto-code {
display: flex;
flex-direction: column;
}
.mm-auto-code__preview {
min-height: 32px;
padding: 4px 10px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-secondary-hover);
color: var(--fms-text);
font-family: Consolas, Monaco, monospace;
line-height: 22px;
}
</style>
@@ -0,0 +1,285 @@
<script setup>
/**
* 模块基础信息面板(s_module)。
* 注意:新系统将图标/路由移到 s_menu,模块本身只维护数据语义:
* category 分类节点 / data 数据模块 / query 查询模块。
*/
import { computed, ref, watch } from 'vue'
import { Languages } from '@lucide/vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
data: { type: Object, default: null },
parentName: { type: String, default: '' },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'i18nSaved'])
const formData = ref(null)
watch(
() => props.data,
(val) => {
if (val === formData.value) return
formData.value = val ? { ...val } : null
},
{ immediate: true },
)
const typeOptions = [
{ label: '分类', value: 'category' },
{ label: '数据模块', value: 'data' },
{ label: '查询模块', value: 'query' },
]
const deletePolicyOptions = [
{ label: '禁止删除(restrict)', value: 'restrict' },
{ label: '生命周期(lifecycle)', value: 'lifecycle' },
{ label: '物理删除(hard)', value: 'hard' },
]
const isCategory = computed(() => formData.value?.b_module_type === 'category')
const isData = computed(() => formData.value?.b_module_type === 'data')
const isQuery = computed(() => formData.value?.b_module_type === 'query')
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => ({
b_code: [
{
required: true,
message: () => (isCategory.value ? '请填写分类编码' : '请填写模块编码'),
},
{
// validateModuleCode 通过时返回空字符串,需转成 true(通过)
validator: (value) => validateModuleCode(value) || true,
},
],
b_name: [{ required: true, message: '请填写模块名称' }],
b_module_type: [{ required: true, message: '请选择模块类型' }],
b_viewtable: [{ required: true, message: '请填写查询视图' }],
b_keyfield: [{ required: true, message: '请填写主键字段' }],
}))
const formRef = ref(null)
// 保存前强制校验:返回带错误的字段列表 [{ name, errors }]
function validate() {
return formRef.value?.validateAll?.() ?? Promise.resolve([])
}
defineExpose({ validate })
function emitUpdate() {
if (formData.value) emit('update', formData.value)
}
function setField(field, value) {
if (!formData.value) return
formData.value[field] = value === '' ? null : value
emitUpdate()
}
// 编码变化时始终以完整编码重新派生多语言键
function onCodeInput(v) {
setField('b_code', v)
if (formData.value) {
formData.value.b_i18n = buildModuleI18nKey(formData.value.b_module_type, v)
emitUpdate()
}
}
function onModuleTypeChange(v) {
if (!formData.value) return
formData.value.b_module_type = v
formData.value.b_i18n = buildModuleI18nKey(v, formData.value.b_code)
emitUpdate()
}
const i18nModalRef = ref(null)
function openI18nModal() {
if (!formData.value || !i18nModalRef.value) return
const key =
formData.value.b_i18n || buildModuleI18nKey(formData.value.b_module_type, formData.value.b_code)
i18nModalRef.value.open(key, formData.value.b_name || '')
}
function onI18nSaved(key) {
setField('b_i18n', key)
emit('i18nSaved', key)
}
</script>
<template>
<div v-if="formData" class="mm-basic">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem :label="isCategory ? '分类编码' : '模块编码'" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="模块名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="模块类型" name="b_module_type" required>
<Select
:model-value="formData.b_module_type"
:options="typeOptions"
:disabled="loading"
@update:model-value="onModuleTypeChange"
/>
</FormItem>
<FormItem label="上级模块" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<template v-if="isData || isQuery">
<FormItem label="查询视图" name="b_viewtable" required>
<Input
:model-value="formData.b_viewtable"
:disabled="loading"
placeholder="data/query 必填"
@update:model-value="(v) => setField('b_viewtable', v)"
/>
</FormItem>
<FormItem label="主键字段" name="b_keyfield" required>
<Input
:model-value="formData.b_keyfield"
:disabled="loading"
placeholder="如 b_id"
@update:model-value="(v) => setField('b_keyfield', v)"
/>
</FormItem>
<FormItem v-if="isData" label="保存表" name="b_savetable">
<Input
:model-value="formData.b_savetable"
:disabled="loading"
placeholder="data 模块的保存目标"
@update:model-value="(v) => setField('b_savetable', v)"
/>
</FormItem>
<FormItem v-if="isData" label="默认排序" name="b_orderfield">
<Input
:model-value="formData.b_orderfield"
:disabled="loading"
placeholder="如 b_xh ASC, b_id DESC"
@update:model-value="(v) => setField('b_orderfield', v)"
/>
</FormItem>
<FormItem v-if="isData" label="删除策略" name="b_delete_policy">
<Select
:model-value="formData.b_delete_policy"
:options="deletePolicyOptions"
:disabled="loading"
clearable
@update:model-value="(v) => setField('b_delete_policy', v)"
/>
</FormItem>
</template>
<FormItem label="描述" name="b_bz" :span="2">
<Input
:model-value="formData.b_bz"
type="textarea"
:rows="2"
:disabled="loading"
@update:model-value="(v) => setField('b_bz', v)"
/>
</FormItem>
</Form>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
</template>
<style lang="scss" scoped>
.mm-basic {
display: flex;
flex-direction: column;
}
.mm-basic__i18n {
display: flex;
align-items: center;
gap: 4px;
:deep(.input-wrapper) {
flex: 1;
min-width: 0;
}
}
.mm-basic__i18n-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
background: var(--fms-secondary);
color: var(--fms-text);
}
}
/* 多语言键行:输入框 + 快捷按钮 */
.mm-basic__i18n {
display: flex;
gap: 6px;
.mm-basic__i18n-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--fms-control-height);
height: var(--fms-control-height);
padding: 0;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
border-color: var(--fms-primary);
}
}
}
</style>
@@ -0,0 +1,217 @@
<script setup>
/**
* 编辑配置面板(s_field_edit):显示/必填/只读/禁用/分组/排序,支持行拖拽与分组管理。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import FieldGroupManager from './FieldGroupManager.vue'
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'groupsSaved', 'syncList', 'syncQuery'])
const localEdit = ref([])
const groupManagerVisible = ref(false)
const activeKey = ref(null)
watch(
() => props.editConfig,
(val) => {
if (val === localEdit.value) return
localEdit.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localEdit.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localEdit.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const groupOptions = computed(() => {
const options = []
for (const group of props.groups || []) {
if (Number(group.b_canuse) !== 1) continue
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) })
}
return options
})
function syncToList() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
}
let count = 0
const updated = (props.listConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_visible: target }
}
return row
})
emit('syncList', updated)
Message.success(`已同步 ${count} 条到列表配置`)
}
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localEdit.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('编辑配置中没有字段')
return
}
let count = 0
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_canuse: target }
}
return row
})
emit('syncQuery', updated)
Message.success(`已同步 ${count} 条到查询配置`)
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{
dataIndex: 'b_visible',
title: '显示',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_required',
title: '必填',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_readonly',
title: '只读',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_disabled',
title: '禁用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'b_group_id',
title: '编辑分组',
width: 160,
editor: { type: 'select', options: () => groupOptions.value },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="outline" :disabled="loading" @click="groupManagerVisible = true">
分组管理
</Button>
<Button type="outline" :disabled="loading || !listConfig.length" @click="syncToList">
同步到列表配置
</Button>
<Button type="outline" :disabled="loading || !queryConfig.length" @click="syncToQuery">
同步到查询配置
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localEdit"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
v-model:open="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
:referenced-group-ids="referencedGroupIds"
@saved="(groups, idMapping) => emit('groupsSaved', groups, idMapping)"
/>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
@@ -0,0 +1,178 @@
<script setup>
/**
* 字段定义面板(s_field):可编辑表格维护模块字段。
* i18n 键按 field.{moduleCode}.{fieldName} 自动同步。
*/
import { h, ref, shallowRef, watch } from 'vue'
import { Languages } from '@lucide/vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: '' },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'i18nSaved', 'syncFields'])
const FIELD_TYPES = [
{ label: '输入框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '数字', value: 'number' },
{ label: '金额', value: 'money' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '复选框', value: 'checkbox' },
{ label: '下拉选择', value: 'select' },
]
const DB_TYPES = [
{ label: 'varchar', value: 'varchar' },
{ label: 'nvarchar', value: 'nvarchar' },
{ label: 'int', value: 'int' },
{ label: 'bigint', value: 'bigint' },
{ label: 'decimal', value: 'decimal' },
{ label: 'date', value: 'date' },
{ label: 'datetime2', value: 'datetime2' },
{ label: 'bit', value: 'bit' },
{ label: 'text', value: 'text' },
]
const localFields = ref([])
const activeKey = ref(null)
const i18nModalRef = ref(null)
watch(
() => [props.fields, props.moduleCode],
([val, moduleCode]) => {
if (val === localFields.value) return
const rows = val?.map((row) => ({ ...row })) || []
const changed = syncModuleItemI18nKeys(rows, 'field', moduleCode, 'b_field')
localFields.value = rows
if (changed) emit('update', rows)
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localFields.value.map((row) => ({ ...row })))
}
function onCellChange() {
syncModuleItemI18nKeys(localFields.value, 'field', props.moduleCode, 'b_field')
emitUpdate()
}
function openI18n(row) {
const key = buildModuleItemI18nKey('field', props.moduleCode, row.b_field)
if (!key) return
i18nModalRef.value?.open(key, row.b_name || row.b_field || '')
}
const I18nKeyCell = ({ row, cellValue }) =>
h('span', { class: 'mm-i18n-cell' }, [
h('span', { class: 'mm-i18n-cell__text' }, cellValue || ''),
h(
'button',
{
type: 'button',
title: '设置多语言',
class: 'mm-i18n-cell__btn',
onClick: (event) => {
event.stopPropagation()
openI18n(row)
},
},
[h(Languages, { size: 14 })],
),
])
const columns = shallowRef([
{ dataIndex: 'b_field', title: '字段名', width: 150 },
{ dataIndex: 'b_name', title: '字段名称', width: 140, editor: { type: 'input' } },
{
dataIndex: 'b_type',
title: '控件类型',
width: 130,
editor: { type: 'select', options: FIELD_TYPES },
},
{
dataIndex: 'b_dbtype',
title: '数据库类型',
width: 130,
editor: { type: 'select', options: DB_TYPES },
},
{
dataIndex: 'b_length',
title: '长度',
width: 80,
editor: { type: 'input', inputType: 'number' },
},
{ dataIndex: 'b_default_value', title: '默认值', width: 120, editor: { type: 'input' } },
{ dataIndex: 'b_i18n', title: '多语言键', width: 220, customCell: I18nKeyCell },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
])
</script>
<template>
<div class="mm-fields">
<FmsTable
v-model:columns="columns"
:data-source="localFields"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
/>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
<style lang="scss" scoped>
.mm-fields {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
}
.mm-i18n-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-cell__btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
}
}
</style>
@@ -0,0 +1,342 @@
<script setup>
/**
* 多语言面板(s_i18n):透视展示——每行一个 b_key,每种语言一列。
* 受管键(模块/字段/权限的 b_i18n)自动维护:默认语言为空时自动跟随当前名称。
* 语言列表使用前端常量(DEFAULT_LANGUAGES),后续可切换语言类型表。
*/
import { computed, h, ref, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
import { cloneData } from '@/utils/dataChanges'
import { consolidatePivotRows } from '@/utils/i18n'
import { loadDataApi } from '@/services/api'
import { DEFAULT_LANGUAGES } from '../common'
const props = defineProps({
moduleId: { type: String, required: true },
/** 受管键项:[{ key, sourceKey, defaultValue, id }] */
i18nItems: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const langOptions = DEFAULT_LANGUAGES
const originalsFlat = ref([])
const pivotRows = ref([])
const isLoading = ref(false)
const searchKey = ref('')
const activeKey = ref(null)
const tableRef = ref(null)
const managedItems = computed(() => props.i18nItems.filter((item) => item && item.managed))
const requestedKeys = computed(() => {
const keys = new Set()
for (const item of props.i18nItems || []) {
if (!item) continue
if (item.key) keys.add(String(item.key))
if (item.sourceKey) keys.add(String(item.sourceKey))
}
return Array.from(keys)
})
function isManagedRow(row) {
return Boolean(row && row._managedId)
}
// 保证模块、字段、权限的受管键始终存在。默认语言为空或仍为上一次默认名称时,
// 自动跟随当前名称;人工修改后的翻译不再被名称变化覆盖。
function reconcileManagedRows(itemsOverride) {
const items = itemsOverride || managedItems.value
const rows = [...pivotRows.value]
const rowByManagedId = new Map()
const unclaimedByKey = new Map()
for (const row of rows) {
if (row._managedId) rowByManagedId.set(String(row._managedId), row)
else unclaimedByKey.set(String(row.b_key), row)
}
const currentManagedIds = new Set()
const defaultLocale = langOptions[0]?.code || ''
for (const item of items) {
const managedId = String(item.id || item.key)
currentManagedIds.add(managedId)
let row = rowByManagedId.get(managedId)
if (!row) row = unclaimedByKey.get(String(item.key))
if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey))
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {} }
rows.push(row)
}
const previousDefault = row._defaultValue
const nextDefault = item.defaultValue == null ? '' : String(item.defaultValue)
if (
defaultLocale &&
(row[defaultLocale] == null ||
row[defaultLocale] === '' ||
row[defaultLocale] === previousDefault)
) {
row[defaultLocale] = nextDefault
}
row.b_key = String(item.key)
row._managedId = managedId
row._defaultValue = nextDefault
row._sortType = String(item.key).split('.', 1)[0]
}
// 已从配置中移除的条目不再受保护,保留其翻译供管理员自行处理
for (const row of rows) {
if (!row._managedId || row._syncInjected || currentManagedIds.has(String(row._managedId)))
continue
delete row._managedId
delete row._defaultValue
}
pivotRows.value = consolidatePivotRows(rows)
}
// 将扁平记录按 key 聚合成一行(每种语言一列)
function buildPivot(flat) {
const byKey = new Map()
for (const r of flat) {
if (!byKey.has(r.b_key)) byKey.set(r.b_key, [])
byKey.get(r.b_key).push(r)
}
const rows = []
for (const [key, recs] of byKey) {
const row = { _id: createTempId(), b_key: key, _localeIds: {} }
for (const r of recs) {
row[r.b_locale] = r.b_value
row._localeIds[r.b_locale] = r.b_id
}
rows.push(row)
}
return rows
}
async function loadI18n() {
const moduleId = String(props.moduleId)
isLoading.value = true
try {
const keys = requestedKeys.value
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'")
const where = keys.length ? 'b_key IN (' + quotedKeys.join(', ') + ')' : null
const res = where ? await loadDataApi('s_i18n', where, 'b_key ASC, b_locale ASC') : { data: [] }
if (String(props.moduleId) !== moduleId) return
const flat = (res.data || []).map((row) => ({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}))
originalsFlat.value = cloneData(flat)
pivotRows.value = buildPivot(flat)
reconcileManagedRows()
} catch (e) {
Message.error('加载多语言失败:' + (e?.message || e))
} finally {
if (String(props.moduleId) === moduleId) isLoading.value = false
}
}
watch(
() => props.moduleId,
() => {
loadI18n()
},
{ immediate: true },
)
function onCellChange() {
reconcileManagedRows()
}
function removeRow(row) {
if (isManagedRow(row)) return
pivotRows.value = pivotRows.value.filter((r) => r !== row)
}
function removeByManagedIds(ids) {
const set = new Set(ids.map(String))
pivotRows.value = pivotRows.value.filter((r) => !set.has(String(r._managedId)))
}
function refreshKey(key) {
if (!key) return
loadI18n()
}
const filteredRows = computed(() => {
const kw = String(searchKey.value || '').trim().toLowerCase()
if (!kw) return pivotRows.value
return pivotRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw))
})
// 动态列:key 列 + 每种语言一列 + 操作列
const columns = computed(() => {
const cols = [
{
dataIndex: 'b_key',
title: '多语言键',
width: 260,
customCell: ({ row }) =>
h('span', { class: 'mm-i18n-key' }, [
h('span', { class: 'mm-i18n-key__text' }, row.b_key || ''),
isManagedRow(row) ? h('span', { class: 'mm-i18n-key__tag' }, '自动') : null,
]),
},
]
for (const lang of langOptions) {
cols.push({
dataIndex: lang.code,
title: lang.name,
width: 200,
editor: { type: 'input' },
})
}
cols.push({
dataIndex: 'actions',
title: '',
width: 60,
actions: [
{
type: 'delete',
label: '删除',
disabled: (row) => isManagedRow(row),
confirm: '确定删除该多语言键?',
onClick: (row) => removeRow(row),
},
],
})
return cols
})
// 供父组件保存时调用:按最终草稿态生成 s_i18n 请求
function buildSaveRequest() {
reconcileManagedRows()
const inserts = []
const updates = []
const deletes = []
const originals = new Map(
originalsFlat.value.map((r) => [`${r.b_key}|${r.b_locale}`, r]),
)
const seen = new Set()
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const cellKey = `${key}|${locale}`
seen.add(cellKey)
const value = row[locale] == null ? '' : String(row[locale])
const original = originals.get(cellKey)
const localeId = row._localeIds?.[locale]
if (localeId) {
if (!original || String(original.b_value ?? '') !== value) {
updates.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value, b_canuse: 1 })
}
} else if (value !== '') {
inserts.push({ b_key: key, b_locale: locale, b_value: value, b_canuse: 1 })
}
}
}
for (const r of originalsFlat.value) {
const cellKey = `${r.b_key}|${r.b_locale}`
if (!seen.has(cellKey)) deletes.push({ b_id: r.b_id })
}
if (!inserts.length && !updates.length && !deletes.length) return null
return { table: 's_i18n', key_field: 'b_id', inserts, updates, deletes }
}
function markSaved() {
const flat = []
for (const row of pivotRows.value) {
const key = String(row.b_key || '')
if (!key) continue
for (const lang of langOptions) {
const locale = lang.code
const localeId = row._localeIds?.[locale]
const value = row[locale] == null ? '' : String(row[locale])
if (localeId) flat.push({ b_id: localeId, b_key: key, b_locale: locale, b_value: value })
else if (value !== '') flat.push({ b_id: null, b_key: key, b_locale: locale, b_value: value })
}
}
originalsFlat.value = flat
}
defineExpose({ buildSaveRequest, markSaved, refreshKey, removeByManagedIds, reconcileManagedRows })
</script>
<template>
<div class="mm-i18n">
<div class="mm-panel__toolbar">
<div class="mm-i18n__search">
<Input v-model="searchKey" placeholder="按多语言键过滤" allow-clear />
</div>
<Button type="outline" :disabled="loading || isLoading" @click="loadI18n">刷新</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="filteredRows"
:editable="!loading && !isLoading"
row-key="_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row._id))"
@change="onCellChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-i18n {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 0 0 8px;
}
.mm-i18n__search {
width: 300px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-key {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
}
.mm-i18n-key__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-key__tag {
flex: none;
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
line-height: 16px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
}
</style>
@@ -0,0 +1,197 @@
<script setup>
/**
* 列表配置面板(s_field_view):列宽/分组/显示/排序,支持行拖拽与分组管理。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Message from '@/components/ui/message/message-manager'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import FieldGroupManager from './FieldGroupManager.vue'
const props = defineProps({
moduleId: { type: String, required: true },
listConfig: { type: Array, default: () => [] },
editConfig: { type: Array, default: () => [] },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
referencedGroupIds: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update', 'groupsSaved', 'syncEdit', 'syncQuery'])
const localList = ref([])
const groupManagerVisible = ref(false)
const activeKey = ref(null)
watch(
() => props.listConfig,
(val) => {
if (val === localList.value) return
localList.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localList.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localList.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
const groupOptions = computed(() => {
const options = []
for (const group of props.groups || []) {
if (Number(group.b_canuse) !== 1) continue
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) })
}
return options
})
function syncToEdit() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('列表配置中没有字段')
return
}
let count = 0
const updated = (props.editConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_visible: target }
}
return row
})
emit('syncEdit', updated)
Message.success(`已同步 ${count} 条到编辑显示`)
}
function syncToQuery() {
if (props.loading) return
const visibleMap = new Map()
for (const row of localList.value) {
if (row.b_field_id != null) {
visibleMap.set(String(row.b_field_id), Number(row.b_visible) === 1 ? 1 : 0)
}
}
if (visibleMap.size === 0) {
Message.info('列表配置中没有字段')
return
}
let count = 0
const updated = (props.queryConfig || []).map((row) => {
const target = visibleMap.get(String(row.b_field_id))
if (target !== undefined) {
count++
return { ...row, b_canuse: target }
}
return row
})
emit('syncQuery', updated)
Message.success(`已同步 ${count} 条到查询配置`)
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{ dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
{
dataIndex: 'b_group_id',
title: '分组',
width: 160,
editor: { type: 'select', options: () => groupOptions.value },
},
{
dataIndex: 'b_visible',
title: '显示',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="outline" :disabled="loading" @click="groupManagerVisible = true">
分组管理
</Button>
<Button type="outline" :disabled="loading || !editConfig.length" @click="syncToEdit">
同步到编辑显示
</Button>
<Button type="outline" :disabled="loading || !queryConfig.length" @click="syncToQuery">
同步到查询配置
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localList"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
v-model:open="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
:referenced-group-ids="referencedGroupIds"
@saved="(groups, idMapping) => emit('groupsSaved', groups, idMapping)"
/>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
@@ -0,0 +1,237 @@
<script setup>
/**
* 权限配置面板(s_power):模块操作权限点(b_power_type=action)。
* i18n 键按 power.{moduleCode}.{powerCode} 自动同步。
*/
import { computed, h, nextTick, ref, shallowRef, watch } from 'vue'
import { Languages } from '@lucide/vue'
import Message from '@/components/ui/message/message-manager'
import Modal from '@/components/ui/modal/modal.vue'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
import { createTempId } from '@/utils/tempId'
import I18nQuickSetModal from './I18nQuickSetModal.vue'
const props = defineProps({
moduleId: { type: String, required: true },
moduleCode: { type: String, default: '' },
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
/** 权限点类型:模块操作 action / 菜单访问 menu */
powerType: { type: String, default: 'action' },
/** 资源编码(b_resource):默认取 moduleCode */
resource: { type: String, default: '' },
/** i18n 键前缀:power / menu */
i18nPrefix: { type: String, default: 'power' },
})
const emit = defineEmits(['update', 'i18nSaved'])
const localPowers = ref([])
const activeKey = ref(null)
const tableRef = ref(null)
watch(
() => [props.powers, props.moduleCode],
([val, moduleCode]) => {
if (val === localPowers.value) return
const rows = val?.map((row) => ({ ...row })) || []
const changed = syncModuleItemI18nKeys(rows, props.i18nPrefix, moduleCode, 'b_code')
localPowers.value = rows
if (changed) emit('update', rows)
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localPowers.value)
}
async function addRow() {
if (props.loading) return
const newId = createTempId()
const newRow = {
b_id: newId,
b_module_id: props.moduleId,
b_code: '',
b_name: '',
b_i18n: null,
b_power_type: props.powerType,
b_resource: props.resource || props.moduleCode || null,
b_operation: props.powerType === 'menu' ? 'access' : 'execute',
b_xh: (localPowers.value.length + 1) * 10,
b_canuse: 1,
}
localPowers.value.push(newRow)
emitUpdate()
activeKey.value = String(newId)
await nextTick()
tableRef.value?.editRow?.(String(newId), 'b_code')
}
function deleteRow(record) {
if (props.loading) return
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id)
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== String(record.b_id))
emitUpdate()
}
const selectedRowKeys = ref([])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys)
localPowers.value = localPowers.value.filter((r) => !delSet.has(String(r.b_id)))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = ''
emitUpdate()
Message.success(`已删除 ${keys.length} 条权限`)
}
function onCellChange() {
syncModuleItemI18nKeys(localPowers.value, props.i18nPrefix, props.moduleCode, 'b_code')
emitUpdate()
}
function openI18n(row) {
const key = buildModuleItemI18nKey(props.i18nPrefix, props.moduleCode, row.b_code)
if (!key) {
Message.warning('请先填写操作编码')
return
}
i18nModalRef.value?.open(key, row.b_name || '')
}
const I18nKeyCell = ({ row, cellValue }) =>
h('span', { class: 'mm-i18n-cell' }, [
h('span', { class: 'mm-i18n-cell__text' }, cellValue ?? ''),
h(
'button',
{
type: 'button',
title: '设置多语言',
class: 'mm-i18n-cell__btn',
onClick: (event) => {
event.stopPropagation()
openI18n(row)
},
},
[h(Languages, { size: 14 })],
),
])
const columns = shallowRef([
{ dataIndex: 'b_code', title: '操作编码', width: 160, editor: { type: 'input' } },
{ dataIndex: 'b_name', title: '操作名称', width: 160, editor: { type: 'input' } },
{ dataIndex: 'b_i18n', title: '多语言键', width: 200, customCell: I18nKeyCell },
{ dataIndex: 'b_xh', title: '排序', width: 90, editor: { type: 'input', inputType: 'number' } },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{
dataIndex: 'actions',
title: '操作',
width: 70,
actions: [
{
type: 'delete',
label: '删除',
confirm: '确定删除该权限点?',
onClick: (row) => deleteRow(row),
},
],
},
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增权限</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</div>
<div class="mm-panel__body">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="localPowers"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
:row-selection="rowSelection"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-i18n-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
}
.mm-i18n-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-i18n-cell__btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: var(--fms-text-secondary);
cursor: pointer;
&:hover {
color: var(--fms-primary);
}
}
</style>
@@ -0,0 +1,160 @@
<script setup>
/**
* 查询配置面板(s_field_query):查询控件/操作符/默认值/启用/排序,支持行拖拽与新增。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import Button from '@/components/ui/button/button.vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { createTempId } from '@/utils/tempId'
const props = defineProps({
moduleId: { type: String, required: true },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const COMPONENT_OPTIONS = [
{ label: '输入框', value: 'input' },
{ label: '下拉选择', value: 'select' },
{ label: '日期选择', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '数字', value: 'number' },
{ label: '复选框', value: 'checkbox' },
]
const OPERATOR_OPTIONS = [
{ label: '等于 (eq)', value: 'eq' },
{ label: '模糊 (like)', value: 'like' },
{ label: '大于 (gt)', value: 'gt' },
{ label: '大于等于 (ge)', value: 'ge' },
{ label: '小于 (lt)', value: 'lt' },
{ label: '小于等于 (le)', value: 'le' },
{ label: '包含 (in)', value: 'in' },
{ label: '范围 (between)', value: 'between' },
]
const localQuery = ref([])
const activeKey = ref(null)
watch(
() => props.queryConfig,
(val) => {
if (val === localQuery.value) return
localQuery.value = val?.map((row) => ({ ...row })) || []
},
{ immediate: true },
)
function emitUpdate() {
emit('update', localQuery.value)
}
function onRowOrderChange(rows) {
const sorted = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
localQuery.value = sorted
emitUpdate()
}
const fieldNameMap = computed(() => {
const map = new Map()
for (const field of props.fields || []) {
map.set(String(field.b_id), field.b_name || field.b_field)
}
return map
})
function addRow() {
if (props.loading) return
localQuery.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_module_id: props.moduleId,
b_field_id: null,
b_relation_id: null,
b_component: 'input',
b_operator: 'eq',
b_default_value: null,
b_xh: (localQuery.value.length + 1) * 10,
b_canuse: 1,
})
emitUpdate()
}
const columns = shallowRef([
{
dataIndex: 'b_field_id',
title: '字段',
width: 180,
customCell: ({ row }) => fieldNameMap.value.get(String(row.b_field_id)) || row.b_field_id || '',
},
{
dataIndex: 'b_component',
title: '查询组件',
width: 150,
editor: { type: 'select', options: COMPONENT_OPTIONS },
},
{
dataIndex: 'b_operator',
title: '操作符',
width: 150,
editor: { type: 'select', options: OPERATOR_OPTIONS },
},
{ dataIndex: 'b_default_value', title: '默认查询值', width: 150, editor: { type: 'input' } },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 90 },
])
</script>
<template>
<div class="mm-panel">
<div class="mm-panel__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">新增查询条件</Button>
</div>
<div class="mm-panel__body">
<FmsTable
v-model:columns="columns"
:data-source="localQuery"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="emitUpdate"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mm-panel {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.mm-panel__toolbar {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 8px;
}
.mm-panel__body {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest'
import { h, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
const model = () =>
ref({
code: 'a',
name: 'b',
note: 'c',
})
function mountForm(columns = 1, extraProps = {}) {
return mount(Form, {
props: { model: model().value, columns, ...extraProps },
slots: {
default: () => [
h(FormItem, { name: 'code', label: '编码' }, { default: () => h('input') }),
h(FormItem, { name: 'name', label: '名称' }, { default: () => h('input') }),
h(FormItem, { name: 'note', label: '备注', span: 2 }, { default: () => h('input') }),
],
},
})
}
describe('Form 多列网格布局', () => {
it('columns=1 时保持原布局(无网格 class)', () => {
const wrapper = mountForm()
expect(wrapper.classes()).not.toContain('fms-form-grid')
expect(wrapper.attributes('style')).toBeUndefined()
})
it('columns>1 时启用网格并生成列模板', () => {
const wrapper = mountForm(2)
expect(wrapper.classes()).toContain('fms-form-grid')
expect(wrapper.element.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
})
it('span>1 的 FormItem 跨列', () => {
const wrapper = mountForm(2)
const items = wrapper.findAllComponents(FormItem)
const noteItem = items.find((i) => i.props('name') === 'note')
expect(noteItem.element.style.gridColumn).toBe('span 2')
const codeItem = items.find((i) => i.props('name') === 'code')
expect(codeItem.element.style.gridColumn).toBe('')
})
it('span 超过列数时取整行', () => {
const wrapper = mount(Form, {
props: { model: model().value, columns: 3 },
slots: {
default: () => [
h(FormItem, { name: 'code', label: '编码', span: 5 }, { default: () => h('input') }),
],
},
})
const item = wrapper.findComponent(FormItem)
expect(item.element.style.gridColumn).toBe('span 3')
})
it('maxWidth 数字按 px 处理', () => {
const wrapper = mountForm(2, { maxWidth: 720 })
expect(wrapper.element.style.maxWidth).toBe('720px')
})
it('vertical 布局下 label 在上', () => {
const wrapper = mountForm(2, { layout: 'vertical' })
const item = wrapper.findComponent(FormItem)
expect(item.classes()).toContain('fms-form-item-vertical')
expect(item.find('.fms-form-label-vertical').exists()).toBe(true)
})
})
describe('Form 必填校验与 FormItem 错误展示', () => {
function mountRequiredForm(rules, modelValue = { code: '' }) {
const model = ref(modelValue)
const wrapper = mount(Form, {
props: { model: model.value, rules },
slots: {
default: () => [h(FormItem, { name: 'code', label: '编码' }, { default: () => h('input') })],
},
})
return { wrapper, model }
}
it('必填为空时提交:FormItem 下方显示错误并标红', async () => {
const { wrapper } = mountRequiredForm({ code: [{ required: true, message: '请填写编码' }] })
await wrapper.find('form').trigger('submit')
await flushPromises()
const item = wrapper.findComponent(FormItem)
expect(item.classes()).toContain('fms-form-item-error')
const help = item.find('.fms-form-item-help-error')
expect(help.exists()).toBe(true)
expect(help.text()).toBe('请填写编码')
expect(wrapper.emitted('submitFailed')).toHaveLength(1)
})
it('message 为函数时按校验时求值(动态文案)', async () => {
const { wrapper } = mountRequiredForm({ code: [{ required: true, message: () => '请填写分类编码' }] })
await wrapper.find('form').trigger('submit')
await flushPromises()
const help = wrapper.findComponent(FormItem).find('.fms-form-item-help-error')
expect(help.text()).toBe('请填写分类编码')
})
it('validateAll 暴露并返回带错误的字段列表', async () => {
const { wrapper } = mountRequiredForm({ code: [{ required: true, message: '请填写编码' }] })
const errors = await wrapper.vm.validateAll()
expect(errors).toHaveLength(1)
expect(errors[0].name).toBe('code')
expect(errors[0].errors).toEqual(['请填写编码'])
})
it('修正必填值后错误清除', async () => {
const { wrapper, model } = mountRequiredForm({ code: [{ required: true, message: '请填写编码' }] })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.findComponent(FormItem).classes()).toContain('fms-form-item-error')
model.value.code = 'abc'
await nextTick()
await flushPromises()
const item = wrapper.findComponent(FormItem)
expect(item.classes()).not.toContain('fms-form-item-error')
expect(item.find('.fms-form-item-help-error').exists()).toBe(false)
})
})
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Tree from '@/components/ui/tree/tree.vue'
const treeData = [{ b_id: '1', b_name: '根节点', children: [{ b_id: '2', b_name: '子节点' }] }]
const fieldNames = { key: 'b_id', title: 'b_name', children: 'children' }
describe('Tree 右键菜单', () => {
it('节点右键发出 contextmenu 事件(携带 node 与 key)', async () => {
const wrapper = mount(Tree, { props: { treeData, fieldNames } })
await wrapper.find('.tree-node-content').trigger('contextmenu')
const emitted = wrapper.emitted('contextmenu')
expect(emitted).toHaveLength(1)
expect(emitted[0][0].node.b_id).toBe('1')
expect(emitted[0][0].key).toBe('1')
expect(emitted[0][0].event).toBeTruthy()
})
it('节点右键不会重复触发树空白处右键逻辑', async () => {
const wrapper = mount(Tree, { props: { treeData, fieldNames } })
await wrapper.find('.tree-node-content').trigger('contextmenu')
expect(wrapper.emitted('contextmenu')).toHaveLength(1)
})
it('空白区域右键发出 contextmenu 事件(node 为 null)', async () => {
const wrapper = mount(Tree, { props: { treeData, fieldNames } })
await wrapper.find('.tree').trigger('contextmenu')
const emitted = wrapper.emitted('contextmenu')
expect(emitted).toHaveLength(1)
expect(emitted[0][0].node).toBeNull()
expect(emitted[0][0].key).toBeNull()
})
it('传入 contextMenu 时按节点渲染菜单(Teleport 到 body)并发出 contextMenuClick', async () => {
const contextMenu = vi.fn((node) =>
node
? [
{ key: 'add', label: '新增子项' },
{ divider: true },
{ key: 'delete', label: '删除', danger: true },
]
: [{ key: 'add-root', label: '新增顶级' }],
)
const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } })
// 节点右键:渲染节点菜单
await wrapper.find('.tree-node-content').trigger('contextmenu')
expect(contextMenu).toHaveBeenCalled()
expect(contextMenu.mock.calls[0][0].b_id).toBe('1')
let menu = document.body.querySelector('.tree-context-menu')
expect(menu).toBeTruthy()
expect(menu.textContent).toContain('新增子项')
const divider = menu.querySelector('.tree-context-menu__divider')
expect(divider).toBeTruthy()
menu.querySelector('.tree-context-menu__item').click()
expect(wrapper.emitted('contextMenuClick')).toHaveLength(1)
expect(wrapper.emitted('contextMenuClick')[0][0].item.key).toBe('add')
expect(wrapper.emitted('contextMenuClick')[0][0].node.b_id).toBe('1')
// 点击后菜单关闭(等待离开动画结束)
await new Promise((r) => setTimeout(r, 150))
expect(document.body.querySelector('.tree-context-menu')).toBeNull()
// 空白右键:渲染顶级菜单
await wrapper.find('.tree').trigger('contextmenu')
menu = document.body.querySelector('.tree-context-menu')
expect(menu.textContent).toContain('新增顶级')
})
it('disabled 菜单项点击不发出 contextMenuClick', async () => {
const contextMenu = () => [{ key: 'x', label: '禁用项', disabled: true }]
const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } })
await wrapper.find('.tree-node-content').trigger('contextmenu')
const menu = document.body.querySelector('.tree-context-menu')
menu.querySelector('.tree-context-menu__item').click()
expect(wrapper.emitted('contextMenuClick')).toBeUndefined()
})
it('点击菜单外部关闭菜单', async () => {
const contextMenu = () => [{ key: 'x', label: '项目' }]
const wrapper = mount(Tree, { props: { treeData, fieldNames, contextMenu } })
await wrapper.find('.tree-node-content').trigger('contextmenu')
expect(document.body.querySelector('.tree-context-menu')).toBeTruthy()
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
await new Promise((r) => setTimeout(r, 0))
expect(document.body.querySelector('.tree-context-menu')).toBeNull()
wrapper.unmount()
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import MenuDetailPanel from '@/views/system/module-management/components/MenuDetailPanel.vue'
const moduleRow = {
b_id: '1',
b_code: 'test',
b_name: '测试模块',
b_module_type: 'data',
b_parent_id: null,
b_canuse: 1,
b_xh: 10,
}
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(async (table) => {
if (table === 's_module') return { data: [moduleRow] }
return { data: [] }
}),
saveObjectApi: vi.fn(async () => ({})),
nextIdApi: vi.fn(async () => ({ data: '999' })),
describeApi: vi.fn(async () => ({ data: [] })),
}))
import ModuleManagement from '@/views/system/module-management/index.vue'
describe('模块管理 tab 切换复现', () => {
let wrapper
beforeEach(async () => {
wrapper = mount(ModuleManagement, {
global: {
plugins: [createPinia()],
stubs: {
ModuleBasicPanel: { template: '<div class="stub-basic" />' },
ModuleFieldsPanel: { template: '<div class="stub-fields" />' },
ModuleListConfigPanel: { template: '<div class="stub-list" />' },
ModuleEditConfigPanel: { template: '<div class="stub-edit" />' },
ModuleQueryConfigPanel: { template: '<div class="stub-query" />' },
ModuleAutoCodePanel: { template: '<div class="stub-autocode" />' },
ModulePowersPanel: { template: '<div class="stub-powers" />' },
ModuleI18nPanel: { template: '<div class="stub-i18n" />' },
MenuDetailPanel: { template: '<div class="stub-menu" />' },
Modal: { template: '<div class="stub-modal" />' },
Transition: { template: '<slot />' },
},
},
})
// 等待树加载
await nextTick()
await nextTick()
})
it('选中模块后 section Tabs 仅一个 tab 激活', async () => {
// 点击树节点选中模块
const node = wrapper.find('.tree-node-content')
expect(node.exists()).toBe(true)
await node.trigger('click')
await nextTick()
await nextTick()
// 等待 loadModuleConfig 完成
await new Promise((r) => setTimeout(r, 50))
await nextTick()
const sectionTabs = wrapper.find('.mm-section-tabs')
expect(sectionTabs.exists()).toBe(true)
const activeTabs = sectionTabs.findAll('.tabs-tab-active')
expect(activeTabs).toHaveLength(1)
// 点击"字段定义"
const fieldsTab = sectionTabs
.findAll('.tabs-tab')
.find((t) => t.text().includes('字段定义'))
expect(fieldsTab).toBeTruthy()
await fieldsTab.trigger('click')
await nextTick()
const activeTabs2 = sectionTabs.findAll('.tabs-tab-active')
expect(activeTabs2).toHaveLength(1)
expect(activeTabs2[0].text()).toContain('字段定义')
})
})
describe('MenuDetailPanel 暴露的保存校验 API', () => {
it('validate / saveI18n / markI18nSaved 同时暴露(defineExpose 未被覆盖)', async () => {
const menu = ref({
b_id: '1',
b_code: '',
b_name: '',
b_menu_type: 'directory',
b_canuse: 1,
})
const wrapper = mount(MenuDetailPanel, {
props: {
menu: menu.value,
parentName: '',
moduleOptions: [],
powers: [],
loading: false,
},
global: {
stubs: {
IconSelect: true,
ModulePowersPanel: true,
ModuleI18nPanel: true,
I18nQuickSetModal: true,
Tabs: { template: '<div><slot /></div>' },
TabPane: { template: '<div><slot /></div>' },
},
},
})
await nextTick()
expect(typeof wrapper.vm.validate).toBe('function')
expect(typeof wrapper.vm.saveI18n).toBe('function')
expect(typeof wrapper.vm.markI18nSaved).toBe('function')
})
it('validate 对空必填返回错误字段,且校验时切回基本信息 tab', async () => {
const menu = ref({
b_id: '1',
b_code: '',
b_name: '',
b_menu_type: 'page',
b_route: '',
b_canuse: 1,
})
const wrapper = mount(MenuDetailPanel, {
props: {
menu: menu.value,
parentName: '',
moduleOptions: [],
powers: [],
loading: false,
},
global: {
stubs: {
IconSelect: true,
ModulePowersPanel: true,
ModuleI18nPanel: true,
I18nQuickSetModal: true,
Tabs: { template: '<div><slot /></div>' },
TabPane: { template: '<div><slot /></div>' },
},
},
})
await nextTick()
const errors = await wrapper.vm.validate()
expect(errors.length).toBeGreaterThan(0)
const names = errors.map((e) => e.name)
expect(names).toContain('b_code')
expect(names).toContain('b_name')
})
})