20260821230656

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

No files matched your search

@@ -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">