20260901173242

This commit is contained in:
oneao committed 2026-09-01 17:32:43 +08:00
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,