20260821230656
This commit is contained in:
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: 正在加载,请稍候…
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 后自动走权限校验
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
+298
@@ -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>
|
||||
+217
@@ -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>
|
||||
+197
@@ -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>
|
||||
+160
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user