20260821230656
This commit is contained in:
1 parent
2e5d532f23
commit
c43b4c8364
144 files changed
+7665
-242
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user