chore(fms-vue): WIP 基线快照——收编工作区未提交的前端改动

不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容:
- ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整,
  新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新
- 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、
  结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、
  分组标题多语言、I18nKeyCell 等(含配套 spec)
- 布局/主题/仪表盘/演示页小幅更新
This commit is contained in:
oneao committed 2026-08-30 20:06:19 +08:00
1 parent a1079a7150
commit 05d0c7f304
58 files changed
+3698 -1343

No files matched your search

@@ -182,9 +182,9 @@ function onMoreClick(info) {
height: 32px;
margin: 0;
padding: 0;
/* 底部三个操作按钮必须是等宽 flex 子项:在 flex-basis: 0% 下浏览器按
内容盒均分剩余空间,任何一方额外的 padding / border 都会让其 border-box
更宽。故这里与 .popover-trigger 统一 padding:0 + border:none,
/* 底部三个操作按钮(含「更多」Dropdown 零包裹后的 Button)必须是等宽 flex 子项:
在 flex-basis: 0% 下浏览器按内容盒均分剩余空间,任何一方额外的 padding / border
都会让其 border-box 更宽。故统一 padding:0 + border:none,
让三个按钮 border-box 完全相等 */
border: none;
border-radius: 0;
@@ -219,22 +219,6 @@ function onMoreClick(info) {
}
}
/* 「更多」按钮被 Dropdown 包裹:Popover 的 .popover-trigger 才是 flex 子项,
必须让它与两个直接按钮一样 flex:1 且 padding/border 保持一致,
三个操作按钮宽度才会一致 */
:deep(.popover-trigger) {
flex: 1;
min-width: 0;
display: flex;
justify-self: auto;
box-sizing: border-box;
padding: 0;
border: none;
.button {
width: 100%;
}
}
}
/* 操作栏按钮间的竖向分隔线:独立元素,不依赖按钮类名 / 禁用态,
@@ -11,6 +11,8 @@ const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
// 边框自绘:true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景)。
// stk-table 库内边框恒为 false,不用其渐变边框。
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
showOverflow: { type: Boolean, default: true },
@@ -37,6 +39,9 @@ const props = defineProps({
default: undefined,
validator: (value) => value === 'light' || value === 'dark',
},
// 行高:唯一真源,同时驱动虚拟滚动计算与库注入的 --row-height 等 CSS 变量,外部勿用 CSS 覆盖
rowHeight: { type: Number, default: 34 },
headerRowHeight: { type: Number, default: 40 },
})
const emit = defineEmits(['update:columns', 'change', 'row-order-change'])
@@ -185,6 +190,7 @@ function rebindActiveEditingRow(rows) {
state.value = {
editing: true,
focusCol: previousState.focusCol,
focused: previousState.focused,
initialValues: captureEditableValues(replacement),
}
}
@@ -203,12 +209,15 @@ function activateEditing(row, focusCol) {
focusCol,
initialValues: captureEditableValues(row),
}
pendingFocusKey = String(getRowKey(row))
return
}
const current = state.value
if (current.editing && current.focusCol === focusCol) return
state.value = { ...current, editing: true, focusCol }
// 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
pendingFocusKey = String(getRowKey(row))
}
function editorValuesEqual(col, previous, current) {
@@ -218,11 +227,29 @@ function editorValuesEqual(col, previous, current) {
return String(previous ?? '') === String(current ?? '')
}
const selectOnFocus = ref(true)
// 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
// 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
// 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
let pendingFocusKey = null
const editingCtx = {
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
shouldSelectOnFocus: selectOnFocus,
// 焦点恢复判定依据:重渲染前该行编辑器是否持有焦点(blur 时已置 false)
wasFocused: (row) => Boolean(getRowEditingState(row)?.value.focused),
consumePendingFocus: (row) => {
if (pendingFocusKey == null) return false
if (pendingFocusKey !== String(getRowKey(row))) return false
pendingFocusKey = null
return true
},
onEditorFocus: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...state.value, focused: true }
},
onEditorBlur: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...state.value, focused: false }
},
shouldNotify: (row, col, newValue) => {
const values = getRowEditingState(row)?.value.initialValues
if (!values?.has(col.dataIndex)) return true
@@ -429,14 +456,11 @@ function onWholeRowDragEnd() {
wholeRowDragSource.value = -1
}
// 行高同时作为 CSS 变量与 StkTable 的 row-height / header-row-height
const rowHeight = 34
const headerRowHeight = 40
// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的
// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算,
// 此处只补充库不管理的表头/表尾背景色。
const customVars = {
'--header-row-height': `${headerRowHeight}px`,
'--row-height': `${rowHeight}px`,
'--footer-row-height': '36px',
'--th-bgc': 'var(--fms-card)',
'--th-bgc': 'var(--fms-table-header-bg)',
'--tf-bgc': 'var(--fms-background)',
}
@@ -456,7 +480,7 @@ async function scrollToRow(rowKey) {
if (!table) return
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
if (idx < 0) return
table.scrollTo(idx * rowHeight, null)
table.scrollTo(idx * props.rowHeight, null)
}
// 新增行等程序化场景使用:滚动到目标行,并让该行立即进入编辑状态。
@@ -603,20 +627,11 @@ function handleAreaSelectionChange(ranges) {
return
}
// 非键盘导航(鼠标点击等):立即激活。
// 同一行已编辑时仍更新 focusCol(保证焦点切换到新列),
// 但暂时禁用 select 全选(避免鼠标点击触发全选文本)。
// 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选
// (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。
pendingEditingTarget = null
clearEditActivateTimer()
if (activeEditingRow === row) {
selectOnFocus.value = false
activateEditing(row, focusCol)
nextTick(() => {
selectOnFocus.value = true
})
} else {
activateEditing(row, focusCol)
}
activateEditing(row, focusCol)
lastEditActivateAt = performance.now()
}
@@ -675,7 +690,10 @@ defineExpose({
<div
ref="rootRef"
class="fms-table"
:class="{ 'is-whole-row-drag': rowDraggable && !rowDragHandle }"
:class="[
{ 'is-whole-row-drag': rowDraggable && !rowDragHandle },
bordered ? 'fms-table--bordered' : '',
]"
@keydown.capture="handleKeydownCapture"
@dragstart="onWholeRowDragStart"
@dragover="onWholeRowDragOver"
@@ -688,8 +706,8 @@ defineExpose({
v-bind="$attrs"
:style="customVars"
:theme="tableTheme"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:row-height="props.rowHeight"
:header-row-height="props.headerRowHeight"
:virtual="effectiveVirtual"
:virtual-x="virtualX"
:smooth-scroll="true"
@@ -728,9 +746,14 @@ defineExpose({
.fms-table {
width: 100%;
height: 100%;
border: 1px solid var(--fms-border);
border-radius: 8px;
overflow: hidden;
/* 自绘外框:描边沿圆弧走,四角闭合(库内边框恒 false,渐变线不参与)。
false 时不描边,嵌入卡片内只留行分隔线 */
&.fms-table--bordered {
border: 1px solid var(--fms-border);
border-radius: 8px;
}
}
/* 整行拖拽:数据行鼠标提示可拖拽;拖拽中的行半透明、落点高亮 */
@@ -769,28 +792,14 @@ defineExpose({
}
}
/*
* 边框由纯 CSS solid border 实现(不用 stk-table 的渐变),彻底避免渐变叠加和粗细不一致问题。
* stk-table bordered=false — 不做任何边框绘制。
* 外层 .fms-table div — 圆角外框(border)。
* 单元格 border-bottom — 行列间的横线。
* 最后一行 tbody tr — 去掉 border-bottom(避免与 div bottom border 重叠)。
*/
/* 自绘行分隔线(库 bordered=false 时不画任何边框):
单元格只画 border-bottom,外框(含四角圆弧描边)由外层 .fms-table--bordered 统一承担,
避免库渐变线叠加与最后一行/外框的双线问题 */
.fms-table__inner.stk-table {
th,
td {
border-bottom: 1px solid var(--fms-border);
}
// 表头底部用稍粗的线或同色线均可,这里保持一致
thead th {
border-bottom-color: var(--fms-border);
}
// 最后一行不画底线,由外层 div 的 border-bottom 统一表现
.stk-tbody-main tr:last-child td {
border-bottom: none;
}
}
.fms-checkbox {
@@ -867,7 +876,7 @@ defineExpose({
.fms-cell-editor {
width: 100%;
height: calc(100% - 4px);
height: 100%;
min-width: 0;
font-size: 13px;
}
@@ -879,6 +888,8 @@ defineExpose({
width: 100%;
height: 100%;
min-height: 0;
// input-wrapper 默认 content-box,height:100% 再加边框会溢出单元格,改边框盒
box-sizing: border-box;
}
.fms-cell-editor-root .select-trigger {
@@ -907,8 +918,14 @@ defineExpose({
white-space: nowrap;
}
// 编辑器所在 td 需要确定高度,否则非虚拟模式下(≤100 行,库不给 td 定高,
// 只有 tr 有 height)子元素 height:100% 无法解析,Input 会塌成内容高度(约 20px)
.fms-table__inner.stk-table .stk-tbody-main td:has(> .fms-cell-editor-root) {
height: var(--row-height);
}
.fms-table__inner td:has(> .fms-cell-editor-root) {
padding-inline: 3px;
padding: 3px;
}
// actions 列按钮容器
@@ -78,11 +78,17 @@ function isEditorDisabled(editor, row) {
function createFocusHandler(ctx, row, col, selectText = false) {
return ({ component, el }) => {
if (!ctx.shouldFocus(row, col)) return
// 仅两类场景聚焦:用户刚激活该单元格(consumePendingFocus 一次性),
// 或重渲染前本编辑器就持有焦点(wasFocused,数据回灌重挂载后恢复焦点)。
// 其余重挂载不聚焦,避免失焦引发的更新把焦点抢回来。
const pending = ctx.consumePendingFocus?.(row)
const wasFocused = Boolean(ctx.wasFocused?.(row))
if (!pending && !wasFocused) return
const input = el?.matches?.('input') ? el : el?.querySelector?.('input')
if (document.activeElement === input) return
const instance = component?.exposed || component?.proxy
const shouldSelect = selectText && (ctx.shouldSelectOnFocus?.value ?? true)
const shouldSelect = selectText && (pending || wasFocused)
const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true }
if (instance?.focus) {
instance.focus(opts)
@@ -104,7 +110,9 @@ function createInput(row, col, editor, ctx) {
'onUpdate:modelValue': (value) => {
setInputDraft(row, col.dataIndex, value)
},
onFocus: () => ctx.onEditorFocus?.(row),
onBlur: (event) => {
ctx.onEditorBlur?.(row)
const value = event.target.value
commitInputDraft(row, col.dataIndex, value)
notifyChange(ctx, editor, row, col, value)
@@ -9,6 +9,8 @@
:deep(svg) {
width: 1em;
height: 1em;
/* 补偿光学视觉差:图标十字视觉重心偏高,微调下移与中文字形对齐 */
margin-top: 1px;
}
padding: 0 var(--fms-control-padding-x);
font-size: var(--fms-font-size);
@@ -21,12 +23,7 @@
transition:
background-color 150ms ease,
border-color 150ms ease,
color 150ms ease,
transform 150ms ease;
}
.button:active {
transform: scale(0.97);
color 150ms ease;
}
.button:focus-visible {
@@ -139,6 +139,7 @@ watch(pickerConfig, () => {
:offset="8"
:disabled="disabled"
:follow-trigger-width="true"
unwrapped
>
<template #trigger>
<div class="fms-date-trigger" @click="toggle">
@@ -10,12 +10,6 @@
/* 窄容器下保持日历可读(7 列日期格子约 31px),
浮层跟随触发器宽度,视觉统一不受影响 */
min-width: 240px;
/* 让触发器撑满组件宽度(覆盖 popover 默认 inline-flex) */
.popover-trigger {
display: block;
width: 100%;
}
}
/* ---------- 触发器(类 Input 外观) ---------- */
@@ -24,7 +18,9 @@
display: flex;
align-items: center;
gap: 6px;
height: var(--fms-control-height);
/* min-height 而非 height:与 Input/Select 一致,内容不足时撑到控件高度,
被放进更矮的容器(如表格编辑单元格)时可被 min-height:0 压缩 */
min-height: var(--fms-control-height);
padding: 0 var(--fms-control-padding-x);
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
@@ -195,6 +195,7 @@ watch(pickerConfig, () => {
:offset="8"
:disabled="disabled"
:follow-trigger-width="true"
unwrapped
>
<template #trigger>
<div class="fms-range-trigger" @click="toggle">
@@ -6,6 +6,7 @@ import Input from '../input/input.vue'
const popoverOpen = ref(false)
const popoverKeyword = ref('')
const popoverUserClicks = ref(0)
const popoverPlacements = [
'top',
'top-start',
@@ -32,20 +33,20 @@ const popoverPlacements = [
<p class="demo-label">基础用法</p>
<div class="demo-panel">
<Popover trigger="click" placement="top" arrow>
<Popover trigger="click" placement="top" arrow unwrapped>
<template #trigger><Button>点击打开</Button></template>
<template #content>
<p class="popover-demo-title">标题</p>
<p class="popover-demo-text">这是一段浮层内容,用于说明当前操作。</p>
</template>
</Popover>
<Popover trigger="hover" placement="top" arrow>
<Popover trigger="hover" placement="top" arrow unwrapped>
<template #trigger><Button type="outline">悬停打开</Button></template>
<template #content>
<p class="popover-demo-text">hover 触发的浮层,移入浮层内不会关闭。</p>
</template>
</Popover>
<Popover trigger="focus" placement="top" arrow>
<Popover trigger="focus" placement="top" arrow unwrapped>
<template #trigger><Input /></template>
<template #content>
<p class="popover-demo-text">focus 触发,失焦后关闭。</p>
@@ -55,7 +56,14 @@ const popoverPlacements = [
<p class="demo-label">十二方位</p>
<div class="demo-panel popover-placement-grid">
<Popover v-for="p in popoverPlacements" :key="p" trigger="hover" :placement="p" arrow>
<Popover
v-for="p in popoverPlacements"
:key="p"
trigger="hover"
:placement="p"
arrow
unwrapped
>
<template #trigger
><Button type="outline">{{ p }}</Button></template
>
@@ -67,7 +75,7 @@ const popoverPlacements = [
<p class="demo-label">携带表单内容</p>
<div class="demo-panel">
<Popover trigger="click" placement="bottom-start">
<Popover trigger="click" placement="bottom-start" unwrapped>
<template #trigger><Button>筛选</Button></template>
<template #content>
<div class="popover-demo-form">
@@ -80,16 +88,47 @@ const popoverPlacements = [
<p class="demo-label">禁用与受控</p>
<div class="demo-panel">
<Popover trigger="click" placement="top" arrow disabled>
<Popover trigger="click" placement="top" arrow disabled unwrapped>
<template #trigger><Button type="outline">禁用</Button></template>
<template #content><p class="popover-demo-text">禁用后不响应触发</p></template>
</Popover>
<Popover v-model:open="popoverOpen" trigger="manual" placement="top" arrow>
<Popover v-model:open="popoverOpen" trigger="manual" placement="top" arrow unwrapped>
<template #trigger><Button type="outline">manual 受控</Button></template>
<template #content><p class="popover-demo-text">由外部状态控制开关</p></template>
</Popover>
<Button type="secondary" @click="popoverOpen = true">打开</Button>
<Button type="secondary" @click="popoverOpen = false">关闭</Button>
</div>
<p class="demo-label">零包裹模式(unwrapped)</p>
<div class="demo-panel">
<Popover trigger="click" placement="top" arrow unwrapped>
<template #trigger><Button>组件作触发器</Button></template>
<template #content>
<p class="popover-demo-text">事件注入到 Button 组件根元素,DOM 中无中间包裹层。</p>
</template>
</Popover>
<Popover trigger="click" placement="top" arrow unwrapped>
<template #trigger
><Button @click="popoverUserClicks++">点击(用户 + 内部)</Button></template
>
<template #content>
<p class="popover-demo-text">
用户 @click 与内部 toggle 均触发,互不覆盖(已点击 {{ popoverUserClicks }} 次)。
</p>
</template>
</Popover>
<Popover trigger="click" placement="top" arrow unwrapped>
<template #trigger>
<Button type="outline">多根</Button>
<Button type="outline">fragment</Button>
</template>
<template #content>
<p class="popover-demo-text">多根插槽无法注入事件,自动回退到 span 包裹层。</p>
</template>
</Popover>
</div>
</section>
</template>
@@ -0,0 +1,80 @@
<script setup>
import { ref } from 'vue'
import { BarChart3, CalendarDays, LayoutGrid, List } from '@lucide/vue'
import Segmented from '../segmented/segmented.vue'
const period = ref('daily')
const view = ref('list')
const align = ref('left')
const withIcon = ref('list')
const blockValue = ref('list')
const verticalValue = ref('list')
const singleDisabled = ref('list')
const groupDisabled = ref('list')
const periodOptions = [
{ label: '日', value: 'daily' },
{ label: '周', value: 'weekly' },
{ label: '月', value: 'monthly' },
]
const viewOptions = [
{ label: '列表', value: 'list', icon: List },
{ label: '卡片', value: 'grid', icon: LayoutGrid },
{ label: '日历', value: 'calendar', icon: CalendarDays },
{ label: '图表', value: 'chart', icon: BarChart3 },
]
// 省略 label 即渲染为纯图标选项
const iconOnlyOptions = viewOptions.map((item) => ({ value: item.value, icon: item.icon }))
const disabledOptions = [
...viewOptions.slice(0, 3),
{ label: '图表(禁用)', value: 'chart', disabled: true },
]
</script>
<template>
<section class="showcase-section" aria-labelledby="segmented-title">
<div class="section-heading">
<p class="eyebrow">Components</p>
<h2 id="segmented-title">Segmented 分段控制器</h2>
<p>在一组互斥选项中切换,等价于带底座滑块的单选组,支持图标、尺寸与方向键导航。</p>
</div>
<p class="demo-label">基础用法(当前值:{{ period }})</p>
<div class="demo-panel">
<Segmented v-model="period" :options="periodOptions" />
</div>
<p class="demo-label">带图标</p>
<div class="demo-panel">
<Segmented v-model="withIcon" :options="viewOptions" />
</div>
<p class="demo-label">仅图标(省略 label)</p>
<div class="demo-panel">
<Segmented v-model="view" :options="iconOnlyOptions" />
</div>
<p class="demo-label">全圆角</p>
<div class="demo-panel">
<Segmented v-model="align" shape="round" :options="periodOptions" />
</div>
<p class="demo-label">通栏(block)</p>
<div class="demo-panel">
<Segmented v-model="blockValue" block :options="viewOptions" />
</div>
<p class="demo-label">纵向</p>
<div class="demo-panel">
<Segmented v-model="verticalValue" vertical :options="viewOptions" />
</div>
<p class="demo-label">禁用态</p>
<div class="demo-panel demo-panel-column">
<Segmented v-model="singleDisabled" :options="disabledOptions" />
<Segmented v-model="groupDisabled" disabled :options="periodOptions" />
</div>
</section>
</template>
@@ -28,8 +28,9 @@ const props = defineProps({
mouseLeaveDelay: { type: Number, default: 200 },
/** 与触发器的间距(px) */
offset: { type: Number, default: 4 },
/** 触发器是否撑满父容器宽度;透传给 Popover(见 Popover.stretchTrigger) */
stretchTrigger: { type: Boolean, default: false },
/** 零包裹模式(默认开启):把事件注入到触发器单根 vnode,不再包 span;
布局由用户内容自己决定;多根/文本节点自动回退到 Popover 的 span 包裹层 */
unwrapped: { type: Boolean, default: true },
})
const emit = defineEmits(['update:open', 'openChange', 'click'])
@@ -180,7 +181,7 @@ provide('dropdownActiveIndex', activeIndex)
:disabled="disabled"
:arrow="false"
:offset="offset"
:stretch-trigger="stretchTrigger"
:unwrapped="unwrapped"
:mouse-enter-delay="mouseEnterDelay"
:mouse-leave-delay="mouseLeaveDelay"
@update:open="onOpenChange"
@@ -54,13 +54,6 @@
}
}
/* 子菜单项包裹在嵌套 Popover 的 trigger 中(inline-flex 会收缩宽度),
改为块级填满 .dropdown-menu,保证整行可 hover / 可点击 */
.dropdown-menu :deep(.popover-trigger) {
display: block;
width: 100%;
}
.dropdown-item:hover:not(.dropdown-item-disabled),
.dropdown-item-active {
background: var(--fms-secondary-hover);
@@ -43,6 +43,7 @@ function onItemClick(item) {
placement="right-start"
:offset="0"
:arrow="false"
unwrapped
>
<template #trigger>
<span
@@ -41,7 +41,7 @@ const rootEl = ref(null)
/* ---------- 合并规则 ---------- */
const mergedRules = computed(() => {
if (Array.isArray(props.rules) && props.rules.length > 0) return props.rules
return form?.rules?.[props.name] || []
return form?.rules?.value?.[props.name] || []
})
/* ---------- 状态 ---------- */
@@ -96,7 +96,7 @@ async function runValidation() {
const nextErrors = await validateRules({
name: props.name,
label: props.label,
value: form?.model?.[props.name],
value: form?.model?.value?.[props.name],
rules: mergedRules.value,
})
errors.value = nextErrors
@@ -119,9 +119,14 @@ function clearValidation() {
// change 触发:值变化即校验。watch 非 immediate,初始值不会触发报红;
// 重置后值被父层清空时(值空且未触碰过)也跳过,避免重置即整片报红
watch(
() => form?.model?.[props.name],
(value) => {
() => [form?.model?.value, form?.model?.value?.[props.name]],
([model, value], [prevModel] = []) => {
if (validateTrigger.value !== 'change') return
// model 对象被父级整体替换(如切换编辑目标):视为全新表单,清除校验态,不触发报红
if (model !== prevModel) {
clearValidation()
return
}
if (!touched.value && isEmptyValue(value)) return
touched.value = true
runValidation()
@@ -138,7 +143,7 @@ function onRootFocusOut(event) {
window.clearTimeout(blurTimer)
blurTimer = window.setTimeout(() => {
// 首次失焦且值为空:仅标记触碰,不报错,避免打开表单即整片报红
if (!touched.value && isEmptyValue(form?.model?.[props.name])) {
if (!touched.value && isEmptyValue(form?.model?.value?.[props.name])) {
touched.value = true
return
}
@@ -95,10 +95,11 @@ function onReset(event) {
}
/* ---------- 上下文 ---------- */
// 下发给 FormItem:简单值用 computed 包裹保证响应式,model / rules 直接传引用
// 下发给 FormItem:model / rules 用 computed 包裹——父级可能整体替换这两个 prop 的对象
// (如切换编辑目标、克隆草稿),provide 只执行一次,直接传引用会让 FormItem 校验读到过期对象
provide('fms-form', {
model: props.model,
rules: props.rules,
model: computed(() => props.model),
rules: computed(() => props.rules),
layout: computed(() => props.layout),
labelAlign: computed(() => props.labelAlign),
labelWidth: computed(() => props.labelWidth),
@@ -19,6 +19,7 @@ import Popover from './popover/popover.vue'
import Radio from './radio/radio.vue'
import RadioGroup from './radio/radio-group.vue'
import Scrollbar from './scrollbar/scrollbar.vue'
import Segmented from './segmented/segmented.vue'
import Select from './select/select.vue'
import Spin from './spin/spin.vue'
import Splitter from './splitter/splitter.vue'
@@ -51,6 +52,7 @@ export {
Radio,
RadioGroup,
Scrollbar,
Segmented,
Select,
Spin,
Splitter,
@@ -256,9 +256,14 @@ onMounted(() => {
nextTick(measureDefaultTextareaHeight)
})
/* 程序化聚焦/选中文本(内联重命名等场景需要 focus + select) */
/* 程序化聚焦/选中文本(内联重命名、表格单元格编辑等场景需要 focus + select) */
defineExpose({
focus: () => inputEl.value?.focus(),
// opts:preventScroll 透传原生 focus;cursor: 'all' 表示聚焦后全选内容
focus: (opts = undefined) => {
const nativeOpts = opts?.preventScroll !== undefined ? { preventScroll: opts.preventScroll } : undefined
inputEl.value?.focus(nativeOpts)
if (opts?.cursor === 'all') inputEl.value?.select()
},
select: () => inputEl.value?.select(),
blur: () => inputEl.value?.blur(),
})
@@ -4,12 +4,6 @@
justify-self: start;
}
/* 触发器撑满父容器(stretch-trigger):结构树等需要占满整行可用宽度的场景 */
.popover-trigger--stretch {
display: block;
width: 100%;
}
/* 在完整轮廓上统一投影,让箭头和主体拥有连续的视觉重量。 */
.popover {
position: fixed;
@@ -35,8 +29,8 @@
/* 与 Antdv Next 一致:箭头是浮层轮廓的一部分,不使用独立边框或阴影。 */
.popover-arrow {
position: absolute;
width: 16px;
height: 16px;
width: 20px;
height: 20px;
z-index: 1;
display: block;
overflow: hidden;
@@ -47,38 +41,40 @@
position: absolute;
inset-inline: 0;
bottom: 0;
width: 16px;
height: 8px;
width: 20px;
height: 10px;
background: var(--fms-card);
clip-path: polygon(0 100%, 50% 0, 100% 100%);
}
/* 浮层在触发器上方,箭头在浮层下边,尖角朝下 */
/* 浮层在触发器上方,箭头在浮层下边,尖角朝下。
位移收 20%(=4px,箭头盒高 20px)让三角形与内容块重叠:
同色背景连成一体,消除 drop-shadow 投影在交界处产生的分割线 */
.popover-top .popover-arrow {
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(100%) rotate(180deg);
transform: translateX(-50%) translateY(80%) rotate(180deg);
}
/* 浮层在触发器下方,箭头在浮层上边,尖角朝上 */
.popover-bottom .popover-arrow {
top: 0;
left: 50%;
transform: translateX(-50%) translateY(-100%);
transform: translateX(-50%) translateY(-80%);
}
/* 浮层在触发器左侧,箭头在浮层右边,尖角朝右 */
.popover-left .popover-arrow {
right: 0;
top: 50%;
transform: translateX(100%) translateY(-50%) rotate(90deg);
transform: translateX(80%) translateY(-50%) rotate(90deg);
}
/* 浮层在触发器右侧,箭头在浮层左边,尖角朝左 */
.popover-right .popover-arrow {
left: 0;
top: 50%;
transform: translateX(-100%) translateY(-50%) rotate(-90deg);
transform: translateX(-80%) translateY(-50%) rotate(-90deg);
}
/* start / end 对齐时,箭头从居中移到靠近触发器边缘一侧 */
@@ -98,10 +94,18 @@
top: 16px;
}
.popover-left-end .popover-arrow,
/* left/right-end:bottom 定位本身已把箭头放到底部,必须同时覆盖 transform
去掉基础的 translateY(-50%)(否则上移半个盒高会把箭头推到矮内容块的顶部) */
.popover-left-end .popover-arrow {
top: auto;
bottom: 4px;
transform: translateX(80%) rotate(90deg);
}
.popover-right-end .popover-arrow {
top: auto;
bottom: 16px;
bottom: 4px;
transform: translateX(-80%) rotate(-90deg);
}
/* 过渡只动 opacity / transform,低成本且支持 reduced-motion */
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Comment, Fragment, cloneVNode, computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from 'vue'
import { PLACEMENTS, computePosition } from '../utils/position.js'
/* ---------- props / emits / slots ---------- */
@@ -35,13 +35,16 @@ const props = defineProps({
/** 浮层宽度是否严格跟随触发器宽度。
用于 Select 等需与触发框等宽对齐的场景;默认 false 由内容自适应撑开。 */
followTriggerWidth: { type: Boolean, default: false },
/** 触发器是否撑满父容器宽度(display:block + width:100%)。
默认 false(inline-flex 收缩到内容宽度,适配按钮/图标触发器);
需要触发器占据整行可用宽度的场景(如结构树)设为 true。 */
stretchTrigger: { type: Boolean, default: false },
/** 零包裹模式:true 时把 6 个事件直接注入触发器单根 vnode(不再包 span),
布局规则由用户内容自己决定(block 天然撑满 / flex 跟随父级);
多根 fragment / 文本节点自动回退到 span 包裹层。
注意:与 antdv 一致,用户触发器节点需能接受
mouseenter/mouseleave/focus/click 事件。 */
unwrapped: { type: Boolean, default: false },
})
const emit = defineEmits(['update:open', 'openChange'])
const slots = useSlots()
/* ---------- 公共状态 ---------- */
const triggerEl = ref(null)
@@ -200,6 +203,77 @@ function onFocusOut(event) {
setOpen(false)
}
/* ---------- 零包裹模式:事件注入 + 锚点指令 ---------- */
// 锚点指令:把用户触发器 vnode 的根 DOM 同步到 triggerEl(定位锚点 / 外部点击
// contains / followTriggerWidth 宽度测量三处复用,语义与包裹层 span 完全一致)。
// 生命周期钩子驱动,无轮询;不覆盖用户自己的 ref / directives。
const triggerRefDirective = {
mounted: (el) => {
triggerEl.value = el
// 受控打开 / contextmenu 已打开后重渲染的场景补一次定位
if (isOpen.value) nextTick(updatePosition)
},
updated: (el) => {
triggerEl.value = el
},
unmounted: (el) => {
if (triggerEl.value === el) triggerEl.value = null
},
}
// 归一化触发器 vnode:unwrapped 且插槽为单根元素/组件时,cloneVNode 注入 6 个
// 事件 handler(mergeProps 自动与用户已有 on* 合并为 [用户, 内部] 数组,两者都触发),
// 并追加锚点指令;多根 fragment / 文本节点返回 null 走 span 包裹层。
// 兼容 Fragment 单子节点:Dropdown 等用 <slot name="trigger"><slot /></slot> 包装触发器时,
// renderSlot 会产出 Fragment vnode;解包到其唯一真实子节点即可正常零包裹。
// computed + slots.trigger 响应式依赖:仅插槽内容或 unwrapped 变化时重算,零冗余开销。
const injectedTrigger = computed(() => {
if (!props.unwrapped) return null
const nodes = slots.trigger?.() ?? []
const real = nodes.filter((n) => n.type !== Comment)
if (real.length !== 1) return null
let vnode = real[0]
// Fragment 单子节点解包:兼容 Dropdown#trigger 嵌套 slot 经 renderSlot 产生的 Fragment。
// 嵌套 slot(<slot name="trigger"><slot /></slot>)会产出多层 Fragment 包裹,
// 循环解包直到拿到真实元素/组件。
while (vnode.type === Fragment) {
const fragChildren = Array.isArray(vnode.children) ? vnode.children : []
const fragReal = fragChildren.filter((n) => n.type !== Comment)
if (fragReal.length !== 1) return null
vnode = fragReal[0]
}
// 文本 / 注释等 symbol 类型 vnode 无法承载事件与指令,回退包裹层
if (typeof vnode.type === 'symbol') return null
const injected = cloneVNode(
vnode,
{
onClick,
onContextmenu: onContextMenu,
onMouseenter: onMouseEnter,
onMouseleave: onMouseLeave,
onFocusin: onFocusIn,
onFocusout: onFocusOut,
},
true,
)
// cloneVNode(merge=true) 在 props 变化时自动把 patchFlag 置 -1、dynamicProps 置 null,
// 保证注入的属性完整参与 diff,不会按旧 flag 跳过更新。
// 注意:Vue 3 vnode 的指令字段名是 `dirs`(非 `directives`),必须用 `dirs`
// 才会被 mountElement 遍历执行;拼成 `directives` 会被静默忽略。
// 注意:必须直接返回 cloneVNode 结果(保留 __v_isVNode / shapeFlag 等内部标记),
// 只在其上挂 dirs 指令;不能对象展开构造新对象,否则渲染函数不会按 vnode 渲染、
// on* 事件和 dirs 都会被丢弃,零包裹模式的事件注入和锚点同步全部失效。
injected.dirs = [...(vnode.dirs || []), { dir: triggerRefDirective }]
return injected
})
// 渲染函数组件包装:<component :is="vnode"> 在 Vue 3 不会可靠地把 vnode 的 on* 事件
// 和 dirs 挂到元素上;用 render-function 组件返回 vnode 才能让 renderer 按 vnode 完整挂载
// (应用 props.on* 为 DOM 监听、遍历 dirs 执行指令、设置锚点 triggerEl)。
const InjectedTriggerComp = computed(() => ({
render: () => injectedTrigger.value,
}))
/* ---------- 全局关闭:click outside / Esc ---------- */
function onDocumentPointerDown(event) {
if (!isOpen.value) return
@@ -249,10 +323,14 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- 零包裹:直接渲染注入事件后的用户 vnode,锚点由指令同步;无中间 DOM -->
<component :is="InjectedTriggerComp" v-if="injectedTrigger" />
<!-- 包裹层 fallback:unwrapped=false、多根 fragment、文本节点三种情况走此分支 -->
<span
v-else
ref="triggerEl"
class="popover-trigger"
:class="{ 'popover-trigger--stretch': stretchTrigger }"
@click="onClick"
@contextmenu="onContextMenu"
@mouseenter="onMouseEnter"
@@ -0,0 +1,160 @@
/* ============================================================
* Segmented 分段控制器
* - 容器为浅灰轨道,选中项由绝对定位的 thumb 白色滑块托底
* - thumb 的位移与尺寸由 JS 测量选中项后写入内联样式,测量完成前不显示
* 尺寸全部取自 --fms-segmented-* token,颜色复用语义色 token。
* ============================================================ */
.segmented {
box-sizing: border-box;
display: inline-block;
padding: var(--fms-segmented-track-padding);
border-radius: var(--fms-segmented-radius);
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: var(--fms-segmented-font-size);
line-height: var(--fms-line-height);
user-select: none;
}
/* 撑满父容器:容器与选项共同分配可用空间 */
.segmented-block {
display: flex;
width: 100%;
}
.segmented-vertical.segmented-block {
width: auto;
height: 100%;
}
/* 选项组:thumb 的定位上下文 */
.segmented-group {
position: relative;
display: flex;
align-items: stretch;
width: 100%;
}
.segmented-vertical .segmented-group {
flex-direction: column;
}
/* ---------- 滑块 ---------- */
.segmented-thumb {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 0;
height: 100%;
border-radius: var(--fms-segmented-item-radius);
background: var(--fms-card);
box-shadow: var(--fms-shadow);
visibility: hidden;
}
.segmented-vertical .segmented-thumb {
width: 100%;
height: 0;
}
/* 测量出首个位置后才显示,避免 0 尺寸的空滑块闪现 */
.segmented-thumb-visible {
visibility: visible;
}
/* ---------- 选项 ---------- */
.segmented-item {
position: relative;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-width: 0;
min-height: var(--fms-segmented-height);
padding: 0 var(--fms-segmented-padding-x);
border-radius: var(--fms-segmented-item-radius);
color: var(--fms-text-secondary);
cursor: pointer;
/* 提升为独立合成层,规避 Safari 下与滑块的层叠闪烁 */
transform: translateZ(0);
transition:
color 150ms ease,
background-color 150ms ease;
}
.segmented-block .segmented-item {
flex: 1 1 0;
}
/* 未选中且未禁用:悬停给浅底色并提亮文字(选中态由 thumb 表达,不叠加悬停底色) */
.segmented-item:not(.segmented-item-selected):not(.segmented-item-disabled):hover {
background: var(--fms-secondary-hover);
color: var(--fms-text);
}
.segmented-item-selected {
color: var(--fms-text);
font-weight: 500;
}
.segmented-item-disabled {
color: var(--fms-disabled-text);
cursor: not-allowed;
}
/* 键盘焦点:原生 radio 视觉隐藏,焦点圈投射到整个选项 */
.segmented-item:has(.segmented-input:focus-visible) {
outline: 2px solid var(--fms-primary);
outline-offset: 2px;
}
/* 原生 radio 不可见但保留可聚焦与同组方向键导航 */
.segmented-input {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
margin: 0;
opacity: 0;
pointer-events: none;
}
/* 标签内容:图标 + 文本居中,超长省略 */
.segmented-item-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.segmented-item-icon {
display: inline-flex;
flex-shrink: 0;
width: 1em;
height: 1em;
}
.segmented-item-icon svg {
width: 100%;
height: 100%;
}
/* 全圆角:覆盖默认的项圆角 */
.segmented-shape-round {
--fms-segmented-radius: 999px;
--fms-segmented-item-radius: 999px;
}
@media (prefers-reduced-motion: reduce) {
.segmented-item {
transition: none;
}
}
@@ -0,0 +1,179 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, useId, watch } from 'vue'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
/** 双向绑定的选中值 */
modelValue: { type: [String, Number, Boolean], default: undefined },
/**
* 选项数据源,与 #label 插槽配合使用:
* ['daily', 'weekly'] 或 [{ label, value, disabled?, icon? }];
* 对象项不给 label 即为纯图标项,icon 可为组件或 VNode
*/
options: { type: Array, default: () => [] },
/** 圆角形状:default 跟随控件圆角,round 全圆角 */
shape: {
type: String,
default: 'default',
validator: (value) => ['default', 'round'].includes(value),
},
/** 纵向排列,滑块上下移动 */
vertical: { type: Boolean, default: false },
/** 撑满父容器宽度(纵向时为高度),各项等分空间 */
block: { type: Boolean, default: false },
/** 禁用整组 */
disabled: { type: Boolean, default: false },
/** 原生 radio 组名,未设置时自动生成,避免多组之间互斥冲突 */
name: { type: String, default: undefined },
})
const emit = defineEmits(['update:modelValue', 'change'])
/* ---------- 选项归一 ---------- */
// 兼容原始值与对象两种形态,保留 index 与 raw 供插槽使用
const mergedOptions = computed(() =>
(props.options || []).map((option, index) => {
const isObject = option !== null && typeof option === 'object'
const value = isObject ? option.value : option
// 对象形态只在显式给了 label 时显示文字(纯图标项留空),原始值形态以自身为文本
const rawLabel = isObject ? option.label : option
return {
value,
label: rawLabel == null ? '' : String(rawLabel),
disabled: isObject ? Boolean(option.disabled) : false,
// options 常由 ref 提供(深层响应式),toRaw 还原组件本身,避免「组件被响应式化」告警
icon: isObject && option.icon ? toRaw(option.icon) : undefined,
index,
raw: option,
}
}),
)
/* ---------- 选中值 ---------- */
// 未传 modelValue 时走非受控:内部保存选中值,仍可通过 change 监听
const innerValue = ref(undefined)
const currentValue = computed(() =>
props.modelValue !== undefined ? props.modelValue : innerValue.value,
)
// useId 依赖实例上下文,必须在 setup 同步阶段取,不能延迟到 computed 求值
const autoName = useId()
const groupName = computed(() => props.name || autoName)
function isSelected(option) {
return Object.is(option.value, currentValue.value)
}
/* ---------- 事件 ---------- */
function onChange(option) {
if (props.disabled || option.disabled) return
// 原生 radio 仅在值变化时派发 change,无需自行去重
if (props.modelValue !== undefined) emit('update:modelValue', option.value)
else innerValue.value = option.value
emit('change', option.value)
}
/* ---------- 滑块测量 ---------- */
const groupEl = ref(null)
const thumbStyle = ref({})
const thumbVisible = ref(false)
let observer = null
// 读取选中项在 group 内的偏移与尺寸,写入 thumb 的内联样式(无过渡,切换即到位)
function measureThumb() {
const group = groupEl.value
if (!group) return
const activeEl = group.querySelector('.segmented-item-selected')
if (!activeEl) {
thumbVisible.value = false
return
}
thumbStyle.value = props.vertical
? { transform: `translateY(${activeEl.offsetTop}px)`, height: `${activeEl.offsetHeight}px` }
: { transform: `translateX(${activeEl.offsetLeft}px)`, width: `${activeEl.offsetWidth}px` }
thumbVisible.value = true
}
onMounted(() => {
nextTick(measureThumb)
// 容器尺寸变化(block 拉伸、字体加载、选项文字变化)时重新定位滑块
if (typeof ResizeObserver !== 'undefined' && groupEl.value) {
observer = new ResizeObserver(() => measureThumb())
observer.observe(groupEl.value)
}
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
// 选中项、选项集或排列方向变化时重新测量;v-for 更新在 nextTick 后才落到 DOM
watch([currentValue, () => props.options, () => props.vertical], () => nextTick(measureThumb))
</script>
<template>
<div
class="segmented"
:class="[
`segmented-shape-${shape}`,
{
'segmented-block': block,
'segmented-vertical': vertical,
'segmented-disabled': disabled,
},
]"
role="radiogroup"
:aria-disabled="disabled || undefined"
:aria-orientation="vertical ? 'vertical' : 'horizontal'"
>
<div ref="groupEl" class="segmented-group">
<!-- 滑块:先于选项渲染,作为选中态底座托在下方 -->
<div
class="segmented-thumb"
:class="{ 'segmented-thumb-visible': thumbVisible }"
:style="thumbStyle"
aria-hidden="true"
/>
<label
v-for="option in mergedOptions"
:key="option.index"
class="segmented-item"
:class="{
'segmented-item-selected': isSelected(option),
'segmented-item-disabled': disabled || option.disabled,
}"
>
<input
class="segmented-input"
type="radio"
:name="groupName"
:value="String(option.value)"
:checked="isSelected(option)"
:disabled="disabled || option.disabled"
@change="onChange(option)"
/>
<span class="segmented-item-label">
<slot
name="label"
:option="option.raw"
:index="option.index"
:checked="isSelected(option)"
>
<component
:is="option.icon"
v-if="option.icon"
class="segmented-item-icon"
aria-hidden="true"
/>
<span v-if="option.label !== ''" class="segmented-item-text">{{ option.label }}</span>
</slot>
</span>
</label>
</div>
</div>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>
@@ -4,13 +4,6 @@
display: inline-block;
vertical-align: middle;
width: 100%;
/* Popover 触发器默认 inline-flex 会收缩到内容宽度,
改为块级填满 .select,保证触发器与下拉宽度一致 */
:deep(.popover-trigger) {
display: block;
width: 100%;
}
}
.select-trigger {
@@ -52,7 +45,10 @@
z-index: 1;
}
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入 */
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入。
文本垂直居中由触发器的 flex align-items 负责,行高跟随全局行高即可,
勿写死 var(--fms-control-height)——那会把触发器内容顶到 28px+边框,
破坏 min-height 的约束(表单里实际渲染 30px,表格编辑行被撑高) */
.select-input {
flex: 1;
min-width: 0;
@@ -63,7 +59,7 @@
background: transparent;
color: inherit;
font: inherit;
line-height: var(--fms-control-height);
line-height: var(--fms-line-height);
text-overflow: ellipsis;
}
@@ -45,6 +45,9 @@ const triggerInputRef = ref(null)
const triggerFocused = ref(false)
const editing = ref(false)
const selectedCache = ref(null)
// 选中/清除后焦点会随下拉内容卸载丢到 body,主动交还输入框时会触发 focus 事件,
// 但此时不应重新展开(「聚焦即展开」仅面向用户主动聚焦),用标记抑制这一次。
const suppressNextAutoOpen = ref(false)
const selectId = useId()
const listboxId = `${selectId}-listbox`
@@ -210,9 +213,23 @@ function openDropdown() {
if (!open.value) open.value = true
}
// 程序化归还焦点(选中/清除/取消后):抑制本次聚焦触发的自动展开。
// focus() 在已聚焦时不派发事件,标记可能残留,nextTick 兜底复位避免误吞下一次真实聚焦。
function refocusTrigger() {
suppressNextAutoOpen.value = true
triggerInputRef.value?.focus({ preventScroll: true })
nextTick(() => {
suppressNextAutoOpen.value = false
})
}
// 聚焦(Tab / 点击空白处后聚焦)即展开;已聚焦时再点击浏览器不会重发 focus 事件,
// 故点击也要兜底展开,让用户再次点击触发器即可重新打开下拉
function onTriggerFocus() {
if (suppressNextAutoOpen.value) {
suppressNextAutoOpen.value = false
return
}
openDropdown()
}
@@ -297,6 +314,8 @@ function selectOption(option) {
emit('update:modelValue', option.value)
emit('change', option.value, option)
open.value = false
// 非 filterable 模式焦点在下拉容器上,随浮层卸载会丢失;交还输入框保持键盘流
refocusTrigger()
}
// 供默认插槽使用:按原始值选中并关闭(插槽自定义选项无规范化 option)
@@ -306,6 +325,7 @@ function selectValue(value) {
emit('update:modelValue', value)
emit('change', value, undefined)
open.value = false
refocusTrigger()
}
function closeDropdown() {
@@ -318,7 +338,7 @@ function clearValue() {
activeIndex.value = -1
selectedCache.value = null
// 键盘触发清除时按钮会被卸载,先把焦点交还输入框,避免焦点丢失后浮层残留。
triggerInputRef.value?.focus({ preventScroll: true })
refocusTrigger()
emit('update:modelValue', undefined)
emit('change', undefined, undefined)
}
@@ -353,6 +373,8 @@ function cancelEditing() {
editing.value = false
filterQuery.value = ''
open.value = false
// Esc 取消:焦点在下拉容器上会随卸载丢失,交还输入框保持键盘流
refocusTrigger()
}
function onTriggerKeydown(event) {
@@ -417,6 +439,7 @@ function onTriggerKeydown(event) {
:offset="4"
:disabled="disabled"
:follow-trigger-width="true"
unwrapped
@update:open="onOpenChange"
>
<!-- 触发器:类 Input 的框体,直接在此输入框键入即可筛选;含清除按钮与折叠箭头 -->
@@ -36,6 +36,7 @@ const emit = defineEmits(['update:open', 'openChange'])
:mouse-enter-delay="mouseEnterDelay"
:mouse-leave-delay="mouseLeaveDelay"
:disabled="disabled"
unwrapped
@update:open="emit('update:open', $event)"
@open-change="emit('openChange', $event)"
>