20260914223013
This commit is contained in:
1 parent
549c8bd2eb
commit
63514476d7
60 files changed
+6478
-2004
No files matched your search
@@ -50,6 +50,11 @@ const extraConditionCount = computed(
|
||||
extraQuickItems.value.filter((item) => hasConditionValue(filters[item.field.b_field])).length,
|
||||
)
|
||||
|
||||
/** 网格列数按实际可见条件数收缩(最多 4 列):只有一个条件时不应占满 4 列宽 */
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.min(Math.max(visibleQuickItems.value.length, 1), 4)}, minmax(0, 200px))`,
|
||||
}))
|
||||
|
||||
function initializeDrafts() {
|
||||
for (const key of Object.keys(filters)) delete filters[key]
|
||||
for (const item of props.queryItems) {
|
||||
@@ -77,7 +82,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
|
||||
<div class="fms-query-toolbar">
|
||||
<div class="query-card">
|
||||
<div class="query-layout">
|
||||
<div class="query-grid">
|
||||
<div class="query-grid" :style="gridStyle">
|
||||
<div v-for="item in visibleQuickItems" :key="item.field.b_field" class="query-field">
|
||||
<label class="query-field__label">{{ labelOf(item) }}</label>
|
||||
<FmsQueryControl
|
||||
@@ -139,7 +144,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
/* 常用条件网格:固定一行 4 个、每列最大 264px;flex 不撑满(宽屏右侧留白)、窄屏可收缩 */
|
||||
/* 常用条件网格:一行最多 4 个、每列最大 200px;列数按实际条件数收缩(行内 style 控制),窄屏可收缩 */
|
||||
.query-grid {
|
||||
display: grid;
|
||||
flex: 0 1 auto;
|
||||
|
||||
@@ -90,6 +90,8 @@ const effectiveTreeData = computed(() => {
|
||||
[fn.value.key]: props.rootKey,
|
||||
isFmsRoot: true,
|
||||
isLeaf: false,
|
||||
// 虚拟根是整棵树的容器,不参与拖拽排序(仍可作为落点,把节点拖进根 = 移到顶层)
|
||||
draggable: false,
|
||||
[fn.value.children]: props.treeData,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Modal from '../modal/modal.vue'
|
||||
import { Modal } from '../index.js'
|
||||
import Button from '../button/button.vue'
|
||||
import Message from '../message/message-manager.js'
|
||||
|
||||
@@ -26,6 +26,20 @@ function saveWithFeedback() {
|
||||
modalFeedbackOpen.value = false
|
||||
Message.success('保存成功')
|
||||
}
|
||||
|
||||
/** 命令式确认:onOk 返回 Promise 时按钮进入 loading,reject 则保持打开 */
|
||||
function openConfirm() {
|
||||
Modal.confirm({
|
||||
title: '确定删除这条记录?',
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
Message.success('已删除')
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,6 +76,11 @@ function saveWithFeedback() {
|
||||
<Button type="outline" @click="modalFeedbackOpen = true">保存并提示</Button>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">命令式确认(Modal.confirm)</p>
|
||||
<div class="demo-panel">
|
||||
<Button type="outline" @click="openConfirm">删除确认</Button>
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="modalBasicOpen" title="基础模态框" :width="440">
|
||||
<p>
|
||||
这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import Space from '../space/space.vue'
|
||||
import Button from '../button/button.vue'
|
||||
import Tag from '../tag/tag.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="showcase-section" aria-labelledby="space-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Components</p>
|
||||
<h2 id="space-title">Space 间距</h2>
|
||||
<p>设置并排子元素之间的统一间距,避免它们紧贴在一起;支持方向、间距、对齐、换行与分隔符。</p>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">基础用法(水平排列按钮)</p>
|
||||
<div class="demo-panel">
|
||||
<Space>
|
||||
<Button type="primary">主要</Button>
|
||||
<Button type="secondary">次要</Button>
|
||||
<Button type="outline">描边</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">间距大小 size:small / middle / large / 自定义数字</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space size="small">
|
||||
<span class="space-box">small</span>
|
||||
<span class="space-box">8px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space size="middle">
|
||||
<span class="space-box">middle</span>
|
||||
<span class="space-box">16px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space size="large">
|
||||
<span class="space-box">large</span>
|
||||
<span class="space-box">24px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
<Space :size="32">
|
||||
<span class="space-box">数字</span>
|
||||
<span class="space-box">32px</span>
|
||||
<span class="space-box">间距</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">垂直方向 vertical(size 数组 [水平, 垂直])</p>
|
||||
<div class="demo-panel">
|
||||
<Space vertical :size="[16, 12]">
|
||||
<span class="space-box space-box-wide">第一行</span>
|
||||
<span class="space-box space-box-wide">第二行</span>
|
||||
<span class="space-box space-box-wide">第三行</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">对齐 align:start / center / end / baseline(子项高度不一)</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space align="start" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">start</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">对齐</span>
|
||||
</Space>
|
||||
<Space align="center" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">center</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">居中</span>
|
||||
</Space>
|
||||
<Space align="end" class="space-demo-row">
|
||||
<span class="space-box space-box-sm">end</span>
|
||||
<span class="space-box space-box-lg">高</span>
|
||||
<span class="space-box space-box-sm">对齐</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">自动换行 wrap(容器宽度不足时折行)</p>
|
||||
<div class="demo-panel">
|
||||
<Space wrap :size="12" class="space-demo-wrap">
|
||||
<Tag v-for="n in 12" :key="n" variant="secondary">标签 {{ n }}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">分隔符 separator(插槽)</p>
|
||||
<div class="demo-panel space-demo-stack">
|
||||
<Space>
|
||||
<a class="space-link" href="#" @click.prevent>编辑</a>
|
||||
<a class="space-link" href="#" @click.prevent>删除</a>
|
||||
<a class="space-link" href="#" @click.prevent>更多</a>
|
||||
<template #separator><span class="space-demo-sep">|</span></template>
|
||||
</Space>
|
||||
<Space>
|
||||
<span>首页</span>
|
||||
<span>分类</span>
|
||||
<span>详情</span>
|
||||
<template #separator><span class="space-demo-sep">/</span></template>
|
||||
</Space>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 演示专用:给间距一个可见的承载块,方便肉眼比对不同 size 的差异 */
|
||||
.space-demo-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.space-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-primary);
|
||||
color: var(--fms-primary-text);
|
||||
font-size: var(--fms-font-size);
|
||||
}
|
||||
|
||||
.space-box-wide {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.space-box-sm {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.space-box-lg {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.space-demo-row {
|
||||
height: 56px;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
.space-demo-wrap {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.space-link {
|
||||
color: var(--fms-primary);
|
||||
font-size: var(--fms-font-size);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.space-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.space-demo-sep {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ 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'
|
||||
import { confirm as modalConfirm } from './modal/modal-confirm.js'
|
||||
import Pagination from './pagination/pagination.vue'
|
||||
import Popover from './popover/popover.vue'
|
||||
import Radio from './radio/radio.vue'
|
||||
@@ -34,6 +35,9 @@ import Tree from './tree/tree.vue'
|
||||
|
||||
import { Message } from './message/message-manager'
|
||||
|
||||
/** 命令式确认对话框:Modal.confirm({ title, content, okText, onOk, ... }) */
|
||||
Modal.confirm = modalConfirm
|
||||
|
||||
export {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import ModalConfirm from './modal-confirm.vue'
|
||||
|
||||
/**
|
||||
* 命令式确认对话框:自建容器挂到 body,关闭动画结束(Modal 的 closed 事件)
|
||||
* 后才卸载容器,保证退场动画完整播放。
|
||||
*
|
||||
* 用法:
|
||||
* Modal.confirm({
|
||||
* title: '确定删除?',
|
||||
* content: '此操作不可恢复',
|
||||
* okText: '删除',
|
||||
* okButtonProps: { danger: true },
|
||||
* onOk: async () => { ... }, // 返回 Promise 时按钮进入 loading;reject 保持打开
|
||||
* onCancel: () => {},
|
||||
* })
|
||||
*
|
||||
* @returns {{ destroy: () => void }} destroy 立即关闭并卸载(不等退场动画)
|
||||
*/
|
||||
export function confirm(options = {}) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
let destroyed = false
|
||||
function destroy() {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
render(null, container)
|
||||
container.remove()
|
||||
}
|
||||
|
||||
render(createVNode(ModalConfirm, { ...options, onClosed: destroy }), container)
|
||||
|
||||
return { destroy }
|
||||
}
|
||||
|
||||
export default confirm
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 命令式确认对话框(由 Modal.confirm 动态挂载,业务不直接使用)。
|
||||
*
|
||||
* onOk 返回 Promise 时确定按钮进入 loading:resolve 后关闭,reject 保持打开,
|
||||
* 错误由调用方负责提示。
|
||||
*
|
||||
* 关闭来源与 onCancel 的关系:只有点「取消」按钮才算显式取消、触发 onCancel;
|
||||
* 点遮罩空白 / Esc / 右上角关闭都是「静默关闭」——只关窗不跑 onCancel,
|
||||
* 避免用户随手点空白就误触发取消逻辑。
|
||||
* 与 antdv 的 Modal.confirm 有意不同:这里允许点遮罩空白处关闭(antdv 默认禁止)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import Modal from './modal.vue'
|
||||
import Button from '../button/button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
okText: { type: String, default: '确定' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
/** 透传给确定按钮;danger: true 等价于 type: 'danger' */
|
||||
okButtonProps: { type: Object, default: () => ({}) },
|
||||
onOk: { type: Function, default: null },
|
||||
onCancel: { type: Function, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['closed'])
|
||||
|
||||
const visible = ref(true)
|
||||
const loading = ref(false)
|
||||
|
||||
const okProps = computed(() => {
|
||||
const { danger, ...rest } = props.okButtonProps
|
||||
return danger && !rest.type ? { ...rest, type: 'danger' } : rest
|
||||
})
|
||||
|
||||
async function handleOk() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await props.onOk?.()
|
||||
} catch {
|
||||
// 失败保持打开,等用户处理后重试或取消
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 显式点「取消」按钮:触发 onCancel 并关闭(唯一会跑 onCancel 的路径) */
|
||||
function handleCancel() {
|
||||
if (loading.value) return
|
||||
props.onCancel?.()
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 不绑 @cancel:遮罩 / Esc / 右上角 X 关闭时 Modal 会 emit('cancel'),
|
||||
这里故意不接,让这些路径「静默关闭」而不触发 onCancel;
|
||||
关窗本身由 v-model:open 完成(Modal 内部 setOpen(false) → update:open)。 -->
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:width="416"
|
||||
:confirm-loading="loading"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<p v-if="content" class="fms-modal-confirm-content">{{ content }}</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="loading" @click="handleCancel">{{ cancelText }}</Button>
|
||||
<Button v-bind="okProps" :loading="loading" @click="handleOk">{{ okText }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fms-modal-confirm-content {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@ const props = defineProps({
|
||||
confirmLoading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'ok', 'cancel'])
|
||||
const emit = defineEmits(['update:open', 'ok', 'cancel', 'closed'])
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const innerOpen = ref(props.defaultOpen)
|
||||
@@ -137,54 +137,61 @@ function onPanelKeydown(event) {
|
||||
trapFocus(event, panelRef.value)
|
||||
}
|
||||
|
||||
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ---------- */
|
||||
watch(isOpen, (value) => {
|
||||
if (value) {
|
||||
// 记录触发元素,关闭后还原焦点
|
||||
triggerEl = document.activeElement
|
||||
lockScroll()
|
||||
scrollLocked = true
|
||||
nextTick(() => {
|
||||
if (!isOpen.value || !panelRef.value) return
|
||||
focusFirst(panelRef.value)
|
||||
panelRef.value.addEventListener('keydown', onPanelKeydown)
|
||||
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
|
||||
dragHandle = bindDrag(panelRef.value, headerRef.value)
|
||||
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
|
||||
if (props.resizable) {
|
||||
resizeHandles = [
|
||||
{ el: resizeERef.value, direction: 'e' },
|
||||
{ el: resizeWRef.value, direction: 'w' },
|
||||
{ el: resizeSRef.value, direction: 's' },
|
||||
{ el: resizeNRef.value, direction: 'n' },
|
||||
]
|
||||
.filter((item) => item.el)
|
||||
.map(({ el, direction }) =>
|
||||
bindResize(panelRef.value, el, {
|
||||
direction,
|
||||
maxWidth: window.innerWidth - 32,
|
||||
maxHeight: window.innerHeight - 124,
|
||||
onResize: (size) => {
|
||||
resizeSize.value = size
|
||||
},
|
||||
}),
|
||||
)
|
||||
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ----------
|
||||
immediate:命令式挂载(Modal.confirm)时 open 初始即为 true,watch 不会因「变化」触发;
|
||||
加 immediate 让打开态初始化(滚动锁 / 焦点陷阱 / Esc / 拖拽)在挂载时执行一次,
|
||||
与受控用法(open 由 false→true)走同一段逻辑。 */
|
||||
watch(
|
||||
isOpen,
|
||||
(value) => {
|
||||
if (value) {
|
||||
// 记录触发元素,关闭后还原焦点
|
||||
triggerEl = document.activeElement
|
||||
lockScroll()
|
||||
scrollLocked = true
|
||||
nextTick(() => {
|
||||
if (!isOpen.value || !panelRef.value) return
|
||||
focusFirst(panelRef.value)
|
||||
panelRef.value.addEventListener('keydown', onPanelKeydown)
|
||||
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
|
||||
dragHandle = bindDrag(panelRef.value, headerRef.value)
|
||||
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
|
||||
if (props.resizable) {
|
||||
resizeHandles = [
|
||||
{ el: resizeERef.value, direction: 'e' },
|
||||
{ el: resizeWRef.value, direction: 'w' },
|
||||
{ el: resizeSRef.value, direction: 's' },
|
||||
{ el: resizeNRef.value, direction: 'n' },
|
||||
]
|
||||
.filter((item) => item.el)
|
||||
.map(({ el, direction }) =>
|
||||
bindResize(panelRef.value, el, {
|
||||
direction,
|
||||
maxWidth: window.innerWidth - 32,
|
||||
maxHeight: window.innerHeight - 124,
|
||||
onResize: (size) => {
|
||||
resizeSize.value = size
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
|
||||
// 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
|
||||
// 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
|
||||
if (dragHandle) {
|
||||
dragHandle.destroy()
|
||||
dragHandle = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 这里只做与动画无关的拆卸(键盘监听、拖拽/缩放手柄)。
|
||||
// 解锁滚动、复位尺寸、还原焦点一律等退场动画结束再做(见 onAfterLeave)——
|
||||
// 否则动画还在播时背景就已经能滚动、焦点也提前跳回触发按钮,屏幕阅读器会跟丢。
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
|
||||
if (dragHandle) {
|
||||
dragHandle.destroy()
|
||||
dragHandle = null
|
||||
resizeHandles.forEach((h) => h.destroy())
|
||||
resizeHandles = []
|
||||
}
|
||||
resizeHandles.forEach((h) => h.destroy())
|
||||
resizeHandles = []
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 退场动画结束后收尾:解锁滚动、复位状态、把焦点还给触发元素 */
|
||||
function onAfterLeave() {
|
||||
@@ -199,6 +206,7 @@ function onAfterLeave() {
|
||||
resizeSize.value = null
|
||||
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
|
||||
triggerEl = null
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
// 全屏与拖拽互斥:进入全屏时停用拖拽并复位位置,退出后重新启用
|
||||
@@ -230,11 +238,13 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 遮罩与内容分两个 Transition:遮罩先铺(base 档),内容晚一拍落(slow 档 + 20ms 延迟);
|
||||
退场不错峰,两者同时走 —— 错峰退场会闪。after-leave 里做收尾(解锁滚动、还原焦点)。 -->
|
||||
<Transition name="fms-modal-mask">
|
||||
退场不错峰,两者同时走 —— 错峰退场会闪。after-leave 里做收尾(解锁滚动、还原焦点)。
|
||||
appear:命令式挂载(Modal.confirm)时 open 初始即为 true,元素首帧就在,
|
||||
默认 Transition 不播入场动画,加 appear 让初次渲染也走一次进入过渡。 -->
|
||||
<Transition name="fms-modal-mask" appear>
|
||||
<div v-if="isOpen && mask" class="fms-modal-mask" aria-hidden="true"></div>
|
||||
</Transition>
|
||||
<Transition name="fms-modal" @after-leave="onAfterLeave">
|
||||
<Transition name="fms-modal" appear @after-leave="onAfterLeave">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fms-modal-wrap"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Space 间距:内联弹性容器,子项之间用 gap 留白。
|
||||
具体间距值由组件按 size 内联注入 row-gap / column-gap,方向 / 对齐 / 换行由容器 class 控制,
|
||||
两者解耦——改预设不影响布局类,改布局类不影响间距。 */
|
||||
|
||||
.space {
|
||||
display: inline-flex;
|
||||
/* 兜底间距:组件总会内联注入 row-gap / column-gap,这里给默认值防止裸用时子项紧贴 */
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 垂直排列:主轴改为纵向(gap 的 row / column 语义随主轴自动对应上下 / 左右间距) */
|
||||
.space-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 交叉轴对齐 */
|
||||
.space-align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.space-align-center {
|
||||
align-items: center;
|
||||
}
|
||||
.space-align-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.space-align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* 自动换行:仅水平方向生效(组件已按方向决定是否加此类) */
|
||||
.space-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 空子项不占位:条件渲染 / 分隔符留下的空包装直接隐藏,避免多出一段 gap */
|
||||
.space-item:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 分隔符:默认与文字同基线居中,颜色退到次级,不与主内容抢视觉 */
|
||||
.space-separator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<script>
|
||||
/**
|
||||
* Space 间距 —— 为并排的子元素设置统一间距,避免它们紧贴在一起。
|
||||
* 参考 antdv-next 的 Space:方向 / 间距大小(预设、数字、[水平, 垂直])/ 对齐 / 自动换行 / 分隔符。
|
||||
*
|
||||
* 与 Grid 的分工:Space 面向「一行内联元素的等距排列」(按钮组、标签行、操作区),
|
||||
* 会给每个子元素包一层 .space-item 以支持分隔符与空节点隐藏;整块页面布局请用 Grid。
|
||||
*
|
||||
* 为何用 render 函数而非 <script setup> 模板:
|
||||
* 分隔符需要「插在子元素之间」,必须先拿到并归一化默认插槽的 vnode 列表,再逐一包裹、
|
||||
* 交错插入分隔符 —— 这类对子节点的操纵用 render 函数最直接(antdv-next 的 Space 同样是 render 实现)。
|
||||
*/
|
||||
import { Comment, Fragment, Text, computed, defineComponent, h } from 'vue'
|
||||
|
||||
/** 预设间距(px):small / middle / large 三档,对齐 antdv 的 paddingXS / padding / paddingLG */
|
||||
const PRESET_GAP = { small: 8, middle: 16, large: 24 }
|
||||
|
||||
/** 把单个 size 值解析为 CSS 长度:预设名查表;纯数字补 px;其余(如 '12px' / '1rem')原样透传 */
|
||||
function toGap(value) {
|
||||
if (value == null) return `${PRESET_GAP.small}px`
|
||||
if (typeof value === 'number') return `${value}px`
|
||||
const text = String(value).trim()
|
||||
if (text in PRESET_GAP) return `${PRESET_GAP[text]}px`
|
||||
return /^\d+(\.\d+)?$/.test(text) ? `${text}px` : text
|
||||
}
|
||||
|
||||
/** 归一化默认插槽 vnode:展开 Fragment、过滤注释与纯空白文本,得到真正的子元素列表 */
|
||||
function normalizeChildren(nodes) {
|
||||
const result = []
|
||||
for (const node of nodes ?? []) {
|
||||
if (node == null || typeof node === 'boolean') continue
|
||||
if (Array.isArray(node)) {
|
||||
result.push(...normalizeChildren(node))
|
||||
continue
|
||||
}
|
||||
if (node.type === Comment) continue
|
||||
if (node.type === Fragment) {
|
||||
result.push(...normalizeChildren(node.children))
|
||||
continue
|
||||
}
|
||||
// 模板换行 / 缩进会产生纯空白文本节点,忽略;非空文本仍作为子元素保留
|
||||
if (node.type === Text && typeof node.children === 'string' && !node.children.trim()) continue
|
||||
result.push(node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FmsSpace',
|
||||
props: {
|
||||
/** 间距大小:预设名 small / middle / large、数字(px)、或 [水平, 垂直] */
|
||||
size: { type: [String, Number, Array], default: 'small' },
|
||||
/** 排列方向;不传时由 vertical 决定 */
|
||||
orientation: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
/** 垂直排列简写;与 orientation 同时配置时以 orientation 优先 */
|
||||
vertical: { type: Boolean, default: false },
|
||||
/** 交叉轴对齐:start / end / center / baseline。水平默认 center,垂直默认不设(stretch) */
|
||||
align: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
validator: (value) => ['start', 'end', 'center', 'baseline'].includes(value),
|
||||
},
|
||||
/** 是否自动换行,仅水平方向生效 */
|
||||
wrap: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const mergedOrientation = computed(
|
||||
() => props.orientation || (props.vertical ? 'vertical' : 'horizontal'),
|
||||
)
|
||||
|
||||
const mergedAlign = computed(() => {
|
||||
if (props.align) return props.align
|
||||
return mergedOrientation.value === 'horizontal' ? 'center' : undefined
|
||||
})
|
||||
|
||||
const containerClass = computed(() =>
|
||||
[
|
||||
'space',
|
||||
`space-${mergedOrientation.value}`,
|
||||
mergedAlign.value ? `space-align-${mergedAlign.value}` : '',
|
||||
props.wrap && mergedOrientation.value === 'horizontal' ? 'space-wrap' : '',
|
||||
].filter(Boolean),
|
||||
)
|
||||
|
||||
// size 归一:数组按 [水平, 垂直] 分别设 column-gap / row-gap;单值同时作用于两轴
|
||||
const gapStyle = computed(() => {
|
||||
const [horizontal, vertical] = Array.isArray(props.size)
|
||||
? props.size
|
||||
: [props.size, props.size]
|
||||
return { columnGap: toGap(horizontal), rowGap: toGap(vertical) }
|
||||
})
|
||||
|
||||
return () => {
|
||||
const children = normalizeChildren(slots.default?.())
|
||||
// 无有效子元素时不渲染容器(与 antdv 一致),避免留下一个空 gap 的盒子
|
||||
if (!children.length) return null
|
||||
|
||||
const separators = normalizeChildren(slots.separator?.())
|
||||
const hasSeparator = separators.length > 0
|
||||
const nodes = []
|
||||
children.forEach((child, index) => {
|
||||
nodes.push(
|
||||
h('div', { class: 'space-item', key: child.key ?? `space-item-${index}` }, [child]),
|
||||
)
|
||||
// 分隔符只插在两个子元素之间,末尾不加
|
||||
if (hasSeparator && index < children.length - 1) {
|
||||
nodes.push(h('span', { class: 'space-separator', key: `space-sep-${index}` }, separators))
|
||||
}
|
||||
})
|
||||
|
||||
// 单一根节点:父级透传的 class / style / 事件由 Vue 自动合并到此 div
|
||||
return h(
|
||||
'div',
|
||||
{ class: containerClass.value, style: gapStyle.value, 'data-slot': 'space' },
|
||||
nodes,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -74,6 +74,7 @@
|
||||
.tree-node-switcher:hover:not(.is-placeholder) {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
}
|
||||
/* 叶子占位:纯对齐用,无交互反馈 */
|
||||
.tree-node-switcher.is-placeholder {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const leaf = computed(() =>
|
||||
isLeafNode(props.node, fn.value, { lazy: ctx.props.lazy, loadedKeys: ctx.loadedSet.value }),
|
||||
)
|
||||
const disabled = computed(() => !!(ctx.props.disabled || props.node.disabled))
|
||||
/** 不可拖拽排序:节点禁用,或节点显式标记 draggable === false(如「全部分类」虚拟根,仍可选中/作为落点) */
|
||||
const dragDisabled = computed(() => disabled.value || props.node?.draggable === false)
|
||||
const disableCheckbox = computed(() => !!props.node.disableCheckbox)
|
||||
const loading = computed(
|
||||
() =>
|
||||
@@ -115,12 +117,12 @@ function onCheckChange() {
|
||||
// 节点一直挂着 is-dragging(opacity .4),表现就是「点了发灰、移开也灰」。
|
||||
const gripArmed = ref(false)
|
||||
const draggableAttr = computed(() =>
|
||||
ctx.props.draggable && !disabled.value && gripArmed.value ? 'true' : 'false',
|
||||
ctx.props.draggable && !dragDisabled.value && gripArmed.value ? 'true' : 'false',
|
||||
)
|
||||
|
||||
/** grip 按下:武装本行拖拽(同步写 DOM,dragstart 可能早于 Vue 的 patch) */
|
||||
function armDrag() {
|
||||
if (disabled.value || !ctx.props.draggable) return
|
||||
if (dragDisabled.value || !ctx.props.draggable) return
|
||||
gripArmed.value = true
|
||||
rowRef.value?.setAttribute('draggable', 'true')
|
||||
// 按下但没真拖(不会产生 dragend)时的兜底解除
|
||||
@@ -132,7 +134,7 @@ function disarmDrag() {
|
||||
rowRef.value?.setAttribute('draggable', 'false')
|
||||
}
|
||||
function onDragStart(e) {
|
||||
if (disabled.value || !gripArmed.value) {
|
||||
if (dragDisabled.value || !gripArmed.value) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -180,9 +182,9 @@ function onContextMenu(e) {
|
||||
@dragend="onDragEnd"
|
||||
@contextmenu.stop="onContextMenu"
|
||||
>
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable) -->
|
||||
<!-- 拖拽手柄:整行唯一可发起拖拽的区域(按下时才把本行置为 draggable);不可拖拽的节点不渲染 -->
|
||||
<span
|
||||
v-if="ctx.props.draggable"
|
||||
v-if="ctx.props.draggable && !dragDisabled"
|
||||
class="tree-node-grip"
|
||||
title="拖拽调整顺序"
|
||||
@mousedown="armDrag"
|
||||
@@ -190,7 +192,9 @@ function onContextMenu(e) {
|
||||
<GripVertical :aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<!-- 展开/收起箭头:叶子占位对齐;懒加载中显示转圈 -->
|
||||
<!-- 展开/收起箭头:非叶子渲染真实箭头,叶子渲染等宽透明占位。
|
||||
占位不是多占地方,而是对齐机制:没有它,叶子标题会左移一个箭头宽,
|
||||
比同层非叶子靠左、并与父级标题齐平(缩进不足),层级关系丢失。 -->
|
||||
<span
|
||||
v-if="!leaf"
|
||||
class="tree-node-switcher"
|
||||
|
||||
Reference in new issue
Block a user