20260901173242
This commit is contained in:
1 parent
3bbcafc5be
commit
53aba8b956
21 files changed
+1595
-704
No files matched your search
@@ -100,6 +100,23 @@ const rootEl = ref(null)
|
||||
const pos = ref(null)
|
||||
let dragStart = null // { pointerX, pointerY, originX, originY, width, height }
|
||||
|
||||
/* ---------- 双击隐藏(也可由父级通过 ref 调 show/hide 控制显隐) ---------- */
|
||||
const visible = ref(true)
|
||||
|
||||
function onDblClick() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
|
||||
function onPointerDown(e) {
|
||||
// 重置按钮的点击不触发拖拽
|
||||
if (e.target.closest('.fps-monitor__reset')) return
|
||||
@@ -148,6 +165,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-show="visible"
|
||||
ref="rootEl"
|
||||
class="fps-monitor"
|
||||
:class="{ 'is-dragging': dragStart }"
|
||||
@@ -155,6 +173,7 @@ onBeforeUnmount(() => {
|
||||
aria-label="FPS 性能监控"
|
||||
v-bind="$attrs"
|
||||
@pointerdown="onPointerDown"
|
||||
@dblclick="onDblClick"
|
||||
>
|
||||
<div class="fps-monitor__header">
|
||||
<span class="fps-monitor__label">FPS</span>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 滑块验证码(参考 vben-admin SliderCaptcha),拖拽至最右端完成验证。
|
||||
* 通过 v-model 双向绑定布尔值,值为 true 时表示验证通过。
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Check, ChevronsRight } from '@lucide/vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
text: { type: String, default: '请按住滑块拖动' },
|
||||
successText: { type: String, default: '验证通过' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success', 'start', 'move', 'end'])
|
||||
|
||||
/* ================= 状态 ================= */
|
||||
const wrapperRef = ref(null)
|
||||
const actionRef = ref(null)
|
||||
const barRef = ref(null)
|
||||
|
||||
const isMoving = ref(false)
|
||||
const isPassing = ref(false)
|
||||
const moveStartX = ref(0)
|
||||
const moveDistance = ref(0)
|
||||
const toLeft = ref(false)
|
||||
const actionLeft = ref('0')
|
||||
const barWidth = ref('0')
|
||||
|
||||
// 同步外部 v-model 到内部状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val !== isPassing.value) {
|
||||
isPassing.value = !!val
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 通过后 emit 事件(仅用于通知,v-model 已在 checkPass 中直接 emit)
|
||||
watch(isPassing, (val) => {
|
||||
if (val) {
|
||||
emit('success', { isPassing: true })
|
||||
}
|
||||
})
|
||||
|
||||
/* ================= 拖拽逻辑 ================= */
|
||||
function getEventPageX(e) {
|
||||
if ('pageX' in e) return e.pageX
|
||||
if ('touches' in e && e.touches[0]) return e.touches[0].pageX
|
||||
return 0
|
||||
}
|
||||
|
||||
function getOffset() {
|
||||
const wrapper = wrapperRef.value
|
||||
const action = actionRef.value
|
||||
if (!wrapper || !action) return { actionWidth: 40, offset: 0, wrapperWidth: 220 }
|
||||
const wrapperWidth = wrapper.offsetWidth
|
||||
const actionWidth = action.offsetWidth
|
||||
const offset = wrapperWidth - actionWidth - 6
|
||||
return { actionWidth, offset, wrapperWidth }
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (isPassing.value || props.disabled) return
|
||||
moveStartX.value = getEventPageX(e)
|
||||
const currentLeft = Number.parseInt(actionLeft.value || '0', 10)
|
||||
moveDistance.value = moveStartX.value - currentLeft
|
||||
isMoving.value = true
|
||||
emit('start', e)
|
||||
}
|
||||
|
||||
function handleDragMoving(e) {
|
||||
if (!isMoving.value || isPassing.value) return
|
||||
const action = actionRef.value
|
||||
const bar = barRef.value
|
||||
if (!action || !bar) return
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset()
|
||||
const moveX = getEventPageX(e) - moveDistance.value
|
||||
|
||||
emit('move', { event: e, moveDistance: moveDistance.value, moveX })
|
||||
|
||||
if (moveX >= offset - 2) {
|
||||
// 拖到右边界(容忍 2px 偏差)
|
||||
actionLeft.value = `${wrapperWidth - actionWidth}px`
|
||||
barWidth.value = `${wrapperWidth - actionWidth / 2}px`
|
||||
checkPass()
|
||||
} else if (moveX > 0) {
|
||||
actionLeft.value = `${moveX}px`
|
||||
barWidth.value = `${moveX + actionWidth / 2}px`
|
||||
} else {
|
||||
actionLeft.value = '0'
|
||||
barWidth.value = '0'
|
||||
}
|
||||
}
|
||||
|
||||
// document 级 mouseup 确保鼠标移出 wrapper 也能正确结束拖拽
|
||||
function onDocumentMouseUp(e) {
|
||||
if (!isMoving.value) return
|
||||
handleDragEnd(e)
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
if (!isMoving.value || isPassing.value) return
|
||||
emit('end', e)
|
||||
const action = actionRef.value
|
||||
const bar = barRef.value
|
||||
if (!action || !bar) return
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset()
|
||||
const moveX = getEventPageX(e) - moveDistance.value
|
||||
|
||||
if (moveX >= offset - 2) {
|
||||
// 拖到位
|
||||
actionLeft.value = `${wrapperWidth - actionWidth}px`
|
||||
barWidth.value = `${wrapperWidth - actionWidth / 2}px`
|
||||
checkPass()
|
||||
} else {
|
||||
// 没到位,归位
|
||||
resume()
|
||||
}
|
||||
isMoving.value = false
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
isPassing.value = true
|
||||
isMoving.value = false
|
||||
emit('update:modelValue', true)
|
||||
}
|
||||
|
||||
function resume() {
|
||||
isMoving.value = false
|
||||
isPassing.value = false
|
||||
moveDistance.value = 0
|
||||
emit('update:modelValue', false)
|
||||
// 先加过渡类(toLeft),下一帧再改值 → 从当前位置平滑动画回 0
|
||||
toLeft.value = true
|
||||
nextTick(() => {
|
||||
actionLeft.value = '0'
|
||||
barWidth.value = '0'
|
||||
setTimeout(() => {
|
||||
toLeft.value = false
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mouseup', onDocumentMouseUp)
|
||||
document.addEventListener('touchend', onDocumentMouseUp)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mouseup', onDocumentMouseUp)
|
||||
document.removeEventListener('touchend', onDocumentMouseUp)
|
||||
})
|
||||
|
||||
defineExpose({ resume })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="scaptcha"
|
||||
:class="{ 'scaptcha--disabled': disabled, 'scaptcha--passed': isPassing }"
|
||||
@mousemove="handleDragMoving"
|
||||
@touchmove="handleDragMoving"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
ref="barRef"
|
||||
class="scaptcha__bar"
|
||||
:class="{
|
||||
'scaptcha__bar--left': toLeft,
|
||||
'scaptcha__bar--passed': isPassing,
|
||||
}"
|
||||
:style="{ width: barWidth }"
|
||||
></div>
|
||||
|
||||
<!-- 背景文字 -->
|
||||
<div
|
||||
class="scaptcha__text"
|
||||
:class="{ 'scaptcha__text--passed': isPassing }"
|
||||
>
|
||||
{{ isPassing ? successText : text }}
|
||||
</div>
|
||||
|
||||
<!-- 滑块手柄 -->
|
||||
<div
|
||||
ref="actionRef"
|
||||
class="scaptcha__action"
|
||||
:class="{
|
||||
'scaptcha__action--left': toLeft,
|
||||
'scaptcha__action--disabled': disabled,
|
||||
'scaptcha__action--passed': isPassing,
|
||||
}"
|
||||
:style="{ left: actionLeft }"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
>
|
||||
<ChevronsRight v-if="!isPassing" :size="16" />
|
||||
<Check v-else :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scaptcha {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--fms-control-radius);
|
||||
border: 1px solid var(--fms-border);
|
||||
background: var(--fms-background);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ========== 进度条 ========== */
|
||||
/* 拖拽过程即显示成功绿(浅色调);默认无过渡,跟手实时 */
|
||||
.scaptcha__bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--fms-success) 78%, #fff);
|
||||
border-radius: var(--fms-control-radius) 0 0 var(--fms-control-radius);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 回退归位时才启用宽度过渡,平滑动画回 0 */
|
||||
.scaptcha__bar--left {
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
/* ========== 背景文字(恒黑色,无透明/扫光效果) ========== */
|
||||
.scaptcha__text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--fms-text);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.scaptcha__text--passed {
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* ========== 滑块手柄 ========== */
|
||||
/* 无边框,仅用阴影表现层次(参考 vben shadow-md),拖拽中不出现边框 */
|
||||
.scaptcha__action {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: grab;
|
||||
transition: box-shadow 150ms ease, color 200ms ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scaptcha__action:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.scaptcha__action:active {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 回退归位时才启用 left 过渡,平滑动画回 0 */
|
||||
.scaptcha__action--left {
|
||||
transition: left 300ms ease;
|
||||
}
|
||||
|
||||
.scaptcha__action--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scaptcha__action--passed {
|
||||
color: var(--fms-success);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ========== 禁用态整体 ========== */
|
||||
/* 禁用仅禁止交互(拖拽由 JS 拦截),不做整体半透明——避免通过后的绿/白被压灰混杂 */
|
||||
.scaptcha--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 表单分组容器(纯布局,不碰业务/校验)。
|
||||
* - 在外层 Form 的宫格网格中占位:span 控制占几格(默认 1/-1 占满整行),
|
||||
* 组内再嵌套宫格网格排布 FormItem。
|
||||
* - columns 控制组内宫格列数(默认继承外层 Form 的 columns);
|
||||
* 通过重新 provide columns 覆盖上下文,使组内 FormItem 的 span 自动按组内宫格计算。
|
||||
* - FormItem 仍通过 provide/inject 拿到 Form 的 model/rules 校验上下文,组件不拦截。
|
||||
* - 可选折叠功能(collapsible),默认展开,折叠/展开状态由内部管理或父级 v-model 控制。
|
||||
* - variant 控制标题视觉风格:line(左竖线 + 右折叠图标,默认)| plain(纯文字标题)。
|
||||
*/
|
||||
import { computed, inject, provide, ref } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { normalizeGap, fluidColumnGap } from './form-utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 分组标题;为空则不渲染标题行 */
|
||||
title: { type: String, default: '' },
|
||||
/** 标题视觉风格:line(左竖线+右折叠图标)| plain(纯文字) */
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'plain'].includes(value),
|
||||
},
|
||||
/** 组内宫格列数;默认继承外层 Form 的 columns(=1 时组内不建栅格,子项直接堆叠) */
|
||||
columns: { type: Number, default: null },
|
||||
/** 在外层 Form 宫格中的占格数;默认 1/-1 占满整行,可设 24 等实现并排 */
|
||||
span: { type: Number, default: null },
|
||||
/** 组内栅格间距:单个数字或 { row, col }(同 Form.gap),默认 { row: 12, col: 8 } */
|
||||
gap: { type: [Number, Object], default: undefined },
|
||||
/** 是否显示折叠/展开按钮 */
|
||||
collapsible: { type: Boolean, default: true },
|
||||
/** 初始展开/收起状态(仅 collapsible 生效) */
|
||||
defaultExpanded: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const form = inject('fms-form', null)
|
||||
|
||||
const groupColumns = computed(() => props.columns ?? form?.columns?.value ?? 1)
|
||||
|
||||
// 覆盖 columns 上下文:组内 FormItem 的 span 按组内宫格计算
|
||||
provide('fms-form-group', {
|
||||
columns: groupColumns,
|
||||
})
|
||||
|
||||
const groupStyle = computed(() => ({
|
||||
gridColumn: props.span ? `span ${props.span}` : '1 / -1',
|
||||
}))
|
||||
|
||||
// 组内宫格:列间距按组内列数钳制(fluidColumnGap),窄容器/嵌套宫格下不会横向溢出;
|
||||
// 未传 gap 时默认 { row: 12, col: 8 }(与 index.scss 的 __grid 一致)
|
||||
const gridStyle = computed(() => {
|
||||
if (groupColumns.value <= 1) return undefined
|
||||
const { row, col } = normalizeGap(props.gap ?? { row: 12, col: 8 })
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${groupColumns.value}, minmax(0, 1fr))`,
|
||||
rowGap: `${row ?? 12}px`,
|
||||
columnGap: fluidColumnGap(col ?? 8, groupColumns.value),
|
||||
}
|
||||
})
|
||||
|
||||
/* ================= 折叠 ================= */
|
||||
const internalExpanded = ref(props.defaultExpanded)
|
||||
|
||||
const expanded = computed({
|
||||
get: () => internalExpanded.value,
|
||||
set: (v) => {
|
||||
internalExpanded.value = v
|
||||
emit('update:modelValue', v)
|
||||
},
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
if (!props.collapsible) return
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fms-form-group" :class="`fms-form-group--${variant}`" :style="groupStyle">
|
||||
<div
|
||||
v-if="title"
|
||||
class="fms-form-group__title"
|
||||
:class="{ 'is-collapsible': collapsible }"
|
||||
:title="collapsible ? (expanded ? '点击收起' : '点击展开') : undefined"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<span class="fms-form-group__title-text">{{ title }}</span>
|
||||
<ChevronDown
|
||||
v-if="collapsible"
|
||||
:size="14"
|
||||
class="fms-form-group__collapse-icon"
|
||||
:class="{ 'is-collapsed': !expanded }"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="expanded" class="fms-form-group__grid" :style="gridStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-form-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ========== 标题:line 风格(默认)========== */
|
||||
.fms-form-group__title {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 8px;
|
||||
padding: 4px 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
line-height: 18px;
|
||||
user-select: none;
|
||||
|
||||
&.is-collapsible {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* line 风格:左竖线 + 文字,折叠图标贴右 */
|
||||
.fms-form-group--line .fms-form-group__title-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--fms-primary);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
/* plain 风格:纯文字,无竖线;可折叠时图标仍贴右 */
|
||||
.fms-form-group--plain .fms-form-group__title-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fms-form-group__collapse-icon {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--fms-text-secondary);
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
.fms-form-group__collapse-icon.is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ========== 组内宫格 ========== */
|
||||
.fms-form-group__grid {
|
||||
display: grid;
|
||||
gap: 12px 8px;
|
||||
align-items: start;
|
||||
|
||||
:deep(.fms-form-item) {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -83,9 +83,11 @@ const labelStyle = computed(() => {
|
||||
return { width: labelWidth.value }
|
||||
})
|
||||
|
||||
// 网格布局(Form columns > 1)下跨列:span 超过列数时取整行
|
||||
// 网格布局(Form columns > 1)下跨列:span 超过列数时取整行;
|
||||
// 组内宫格由 FormGroup 提供(fms-form-group),优先于 Form 的 columns
|
||||
const groupForm = inject('fms-form-group', null)
|
||||
const itemStyle = computed(() => {
|
||||
const columns = form?.columns?.value ?? 1
|
||||
const columns = groupForm?.columns?.value ?? 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
|
||||
@@ -120,13 +122,18 @@ function clearValidation() {
|
||||
// 重置后值被父层清空时(值空且未触碰过)也跳过,避免重置即整片报红
|
||||
watch(
|
||||
() => [form?.model?.value, form?.model?.value?.[props.name]],
|
||||
([model, value], [prevModel] = []) => {
|
||||
([model, value], [prevModel, prevValue] = []) => {
|
||||
if (validateTrigger.value !== 'change') return
|
||||
// model 对象被父级整体替换(如切换编辑目标):视为全新表单,清除校验态,不触发报红
|
||||
if (model !== prevModel) {
|
||||
clearValidation()
|
||||
return
|
||||
}
|
||||
// 布尔值由 true 重置为 false(如滑块验证码归零):视为主动重置,清除校验态不报错
|
||||
if (prevValue === true && value === false) {
|
||||
clearValidation()
|
||||
return
|
||||
}
|
||||
if (!touched.value && isEmptyValue(value)) return
|
||||
touched.value = true
|
||||
runValidation()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Form / FormGroup 布局工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* gap 归一化:数字 → { row, col }(行/列等距),对象原样返回,空值返回 {}
|
||||
*/
|
||||
export function normalizeGap(gap) {
|
||||
if (typeof gap === 'number') return { row: gap, col: gap }
|
||||
return gap || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列间距防溢出钳制(宫格嵌套场景)。
|
||||
*
|
||||
* grid 的 column-gap 是固定值且不可压缩:N 列宫格有 N-1 个 gap,
|
||||
* 窄容器下轨道可被 minmax(0, 1fr) 压到 0,gap 压不动,总量超出容器宽即横向溢出
|
||||
* (嵌套宫格外层 gap 还会先吃掉内容宽,留给内层更少)。
|
||||
*
|
||||
* 返回 `min(px, 100% / (2N))`:gap 总量最多占容器约 50%,任何宽度下不会溢出;
|
||||
* 容器够宽时取 px 上限,与固定值视觉一致。百分比相对 grid 自身内容盒解析,
|
||||
* 各层按自己的 N 独立钳制——外层/内层列数可任意组合(默认 48/24 只是约定)。
|
||||
*/
|
||||
export function fluidColumnGap(col, columns) {
|
||||
if (col == null || !columns || columns <= 1) return undefined
|
||||
const maxPercent = (100 / (columns * 2)).toFixed(3).replace(/\.?0+$/, '')
|
||||
return `min(${col}px, ${maxPercent}%)`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, provide } from 'vue'
|
||||
import { fluidColumnGap, normalizeGap } from './form-utils'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
@@ -36,19 +37,49 @@ const props = defineProps({
|
||||
default: 'change',
|
||||
validator: (value) => ['change', 'blur'].includes(value),
|
||||
},
|
||||
/** 表单项间距(参考 antdv-next Row gutter):单个数字表示行/列等距,或对象 { row, col } 分别控制行/列间距,单位 px。
|
||||
* 单列布局(columns <= 1)时 row 生效(纵向堆叠);多列网格(columns > 1)时 row+col 都生效。
|
||||
* 不传则使用样式默认值,不影响现有表单。 */
|
||||
gap: { type: [Number, Object], default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'submitFailed', 'reset'])
|
||||
|
||||
/* ---------- 网格布局 ---------- */
|
||||
/* ---------- 布局样式 ---------- */
|
||||
// 列数 / 最大宽度 / 间距(gap)统一在此应用到根元素。
|
||||
// 单列纵向布局:display:flex + flex-direction:column + row-gap;
|
||||
// 多列网格布局:grid + gap(覆盖 scss 默认值)。
|
||||
// gap 归一化:数字 → { row: n, col: n }(行/列等距)
|
||||
const normalizedGap = computed(() => normalizeGap(props.gap))
|
||||
|
||||
// 是否启用了任意轴向的间距(用于 fms-form-gap 类,取消表单项默认 margin)
|
||||
const hasGap = computed(() => {
|
||||
const { row, col } = normalizedGap.value
|
||||
return row != null || col != null
|
||||
})
|
||||
|
||||
const formStyle = computed(() => {
|
||||
const style = {}
|
||||
if (props.columns > 1) {
|
||||
style.display = 'grid'
|
||||
style.gridTemplateColumns = `repeat(${props.columns}, minmax(0, 1fr))`
|
||||
}
|
||||
if (props.maxWidth != null) {
|
||||
style.maxWidth = typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||
}
|
||||
const { row, col } = normalizedGap.value
|
||||
if (props.columns > 1) {
|
||||
// 多列网格:行间距固定(纵向只增高,不会横向溢出);
|
||||
// 列间距按列数钳制(fluidColumnGap),默认值与 index.scss 的 .fms-form-grid 一致,
|
||||
// 未传 gap 的高列数宫格(如设计器预览 48 列)同样不会在窄容器下溢出
|
||||
style.rowGap = `${row ?? 12}px`
|
||||
style.columnGap = fluidColumnGap(col ?? 16, props.columns)
|
||||
} else if (row != null) {
|
||||
// 单列纵向:flex 列布局用 row-gap 控制表单项间距
|
||||
style.display = 'flex'
|
||||
style.flexDirection = 'column'
|
||||
style.rowGap = `${row}px`
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
@@ -117,7 +148,10 @@ defineExpose({ validateAll })
|
||||
<template>
|
||||
<form
|
||||
class="fms-form"
|
||||
:class="[`fms-form-${layout}`, { 'fms-form-grid': columns > 1 }]"
|
||||
:class="[
|
||||
`fms-form-${layout}`,
|
||||
{ 'fms-form-grid': columns > 1, 'fms-form-gap': hasGap },
|
||||
]"
|
||||
:style="formStyle"
|
||||
@submit="onSubmit"
|
||||
@reset="onReset"
|
||||
|
||||
@@ -7,7 +7,17 @@
|
||||
.fms-form-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--fms-form-item-margin-bottom);
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* gap 模式:表单项间距由 Form 根容器统一控制(flex/grid + gap),
|
||||
取消表单项自身的 margin-bottom 避免双重加距 */
|
||||
.fms-form-gap .fms-form-item {
|
||||
margin-bottom: 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -11,6 +11,7 @@ import TimeSelect from './date/time-select.vue'
|
||||
import Drawer from './drawer/drawer.vue'
|
||||
import Dropdown from './dropdown/dropdown.vue'
|
||||
import Form from './form/form.vue'
|
||||
import FormGroup from './form/FormGroup.vue'
|
||||
import FormItem from './form/form-item.vue'
|
||||
import Input from './input/input.vue'
|
||||
import Modal from './modal/modal.vue'
|
||||
@@ -43,6 +44,7 @@ export {
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormGroup,
|
||||
FormItem,
|
||||
Input,
|
||||
Message,
|
||||
|
||||
Reference in new issue
Block a user