20260825173027

This commit is contained in:
oneao committed 2026-08-25 17:30:28 +08:00
1 parent 7421019ef2
commit a509f93f34
11 files changed
+615 -934

No files matched your search

+6 -2
View File
@@ -248,15 +248,19 @@
<body>
<div id="app">
<script>
// 模块加载失败时(如直接 file:// 打开)展示错误,避免永远卡在加载屏
// 模块加载失败时(如直接 file:// 打开)展示错误,避免永远卡在加载屏。
// 忽略良性噪音错误(ResizeObserver 循环等浏览器警告也会走 error 事件),
// 否则 DevTools 展开/收起等触发 resize 时会把整个页面误替换成错误页。
window.addEventListener('error', (e) => {
const msg = e && e.message ? String(e.message) : ''
if (/ResizeObserver loop/i.test(msg)) return
const app = document.getElementById('app')
if (app) {
app.innerHTML =
'<div id="app-boot-error" class="app-boot app-boot--error" style="position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;font-family:sans-serif">' +
'<div style="font-size:20px;font-weight:bold">加载失败</div>' +
'<div style="font-size:14px">请通过本地开发服务器访问(如 npm run dev),不要直接以 file:// 方式打开本文件。</div>' +
(e && e.message ? '<div style="font-size:12px;opacity:.7">' + e.message + '</div>' : '') +
(msg ? '<div style="font-size:12px;opacity:.7">' + msg + '</div>' : '') +
'</div>'
}
})
+16 -13
View File
@@ -5,7 +5,6 @@ import { useAppStore } from '@/stores/app'
import Splitter from '@/components/ui/splitter/splitter.vue'
import SplitterPanel from '@/components/ui/splitter/panel.vue'
import AppSidebar from './components/AppSidebar.vue'
import AppTabs from './components/AppTabs.vue'
import AppTopbar from './components/AppTopbar.vue'
import DetailPage from '@/views/detail/DetailPage.vue'
import { cacheKeyOf } from '@/utils/cacheKey'
@@ -96,18 +95,18 @@ function onSplitterResize(sizes) {
:min="appStore.sidebarCollapsed ? undefined : SIDEBAR_MIN"
:max="appStore.sidebarCollapsed ? undefined : SIDEBAR_MAX"
:resizable="!appStore.sidebarCollapsed"
class="fms-sidebar-panel"
>
<AppSidebar />
</SplitterPanel>
<!-- 主区面板:顶栏独立在灰底上,下方一张白卡放 tabs + 内容 -->
<!-- 主区面板:顶栏(已含多页签)独立在灰底上,下方一张白卡放内容 -->
<SplitterPanel class="fms-main-panel">
<div class="fms-main-col">
<!-- 顶栏已合并多页签,主区只留内容白卡 -->
<AppTopbar />
<div class="fms-main">
<AppTabs />
<div class="fms-content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
@@ -193,9 +192,7 @@ function onSplitterResize(sizes) {
}
/* 主区面板:四周留 8px 间距(含左侧),与侧栏拉开标准间隙。
* 折叠态(窄侧栏 48px)左侧归 0,白卡贴侧栏右缘,避免过窄时两侧都留间隙。
* 卡片阴影会外扩到面板边界外,须将主区面板 overflow 改为 visible 才不被裁。
* 卡片自身 overflow: hidden + .fms-content 内部滚动,内容不会真正外溢,改为 visible 安全 */
* 折叠态(窄侧栏 48px)左侧归 0,白卡贴侧栏右缘 */
.fms-main-panel {
padding: 8px;
}
@@ -204,6 +201,16 @@ function onSplitterResize(sizes) {
padding: 8px 8px 8px 0;
}
/* 侧栏面板:四周留 8px,白色侧栏卡浮在灰底上(与顶栏同一套间距) */
.fms-sidebar-panel {
padding: 8px;
box-sizing: border-box;
}
.fms-layout.is-collapsed .fms-sidebar-panel {
padding: 8px 8px 8px 0;
}
/* 覆盖 Splitter 面板默认 overflow: auto(scoped 同名规则特异性更高,需带 .fms-layout 提权),
* 让主卡片左侧阴影完整绘制到侧栏背景上(对齐 old2 的 z-index 效果) */
.fms-layout .fms-splitter-panel.fms-main-panel {
@@ -220,7 +227,7 @@ function onSplitterResize(sizes) {
gap: 8px;
}
/* 主区白卡:tabs + 内容一体,圆角 + 阴影,撑满剩余高度 */
/* 主区:透明容器(不再包白卡),内容区直接坐灰底,页面白卡浮在灰上 */
.fms-main {
display: flex;
flex-direction: column;
@@ -228,13 +235,9 @@ function onSplitterResize(sizes) {
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
}
/* 内容区:白卡内滚动区,顶部由 .fms-tabs 的底边线分隔。
* flex column:让页面根可用 flex:1 撑满可视高度(百分比高度链在滚动容器内不可靠) */
/* 内容区:透明,灰底由 .fms-layout 提供(页面白卡直接浮在灰上) */
.fms-content {
flex: 1;
min-height: 0;
@@ -152,12 +152,16 @@ function toNavItem(node) {
</template>
<style lang="scss" scoped>
/* ---- 侧栏容器 ---- */
/* ---- 侧栏容器:白色圆角卡(带框架阴影,浮在灰底上),内部分区滚动 ---- */
.fms-sidebar-body {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow: hidden;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-frame-shadow);
}
/* 团队切换(对齐 SidebarHeader p-2 + TeamSwitcher h-12) */
@@ -65,6 +65,10 @@ onMounted(() => {
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
if (scrollStateRaf != null) {
cancelAnimationFrame(scrollStateRaf)
scrollStateRaf = null
}
trackRef.value?.removeEventListener('wheel', handleWheel)
})
@@ -131,19 +135,33 @@ function handleTabAction(action, key) {
}
}
// 溢出/滚动状态:仅在实际变化时更新,并用 rAF 延迟到下一帧提交 ——
// 箭头 v-if 的显示切换会改变轨道宽度,若在 ResizeObserver 回调内同步改布局,
// 会在同一帧内触发新一轮观察,造成「ResizeObserver loop」错误。
let scrollStateRaf = null
function updateScrollState() {
const track = trackRef.value
if (!track) return
const shouldOverflow = track.scrollWidth > track.clientWidth + 1
overflowed.value = shouldOverflow
if (!shouldOverflow) {
track.scrollLeft = 0
canScrollLeft.value = false
canScrollRight.value = false
const left = shouldOverflow ? track.scrollLeft > 1 : false
const right = shouldOverflow
? track.scrollLeft + track.clientWidth < track.scrollWidth - 1
: false
if (
shouldOverflow === overflowed.value &&
left === canScrollLeft.value &&
right === canScrollRight.value
) {
return
}
canScrollLeft.value = track.scrollLeft > 1
canScrollRight.value = track.scrollLeft + track.clientWidth < track.scrollWidth - 1
if (scrollStateRaf != null) return
scrollStateRaf = requestAnimationFrame(() => {
scrollStateRaf = null
if (!shouldOverflow) track.scrollLeft = 0
overflowed.value = shouldOverflow
canScrollLeft.value = left
canScrollRight.value = right
})
}
function scrollTabs(direction) {
@@ -12,7 +12,6 @@ import {
Moon,
Palette,
PanelLeft,
Search,
Sun,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
@@ -22,7 +21,7 @@ import { Message } from '@/components/ui/message/message-manager'
import { PRESETS } from '@/theme/presets'
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import AppTabs from './AppTabs.vue'
const router = useRouter()
const appStore = useAppStore()
@@ -96,7 +95,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<template>
<header class="fms-topbar">
<!-- 左侧:折叠按钮 + 搜索框 -->
<!-- 左侧:折叠按钮 -->
<div class="fms-topbar-left">
<Button
type="ghost"
@@ -106,20 +105,12 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
>
<PanelLeft />
</Button>
<span class="fms-separator" aria-hidden="true" />
</div>
<Input
class="fms-search"
disabled
model-value=""
placeholder="搜索功能、菜单…"
aria-label="全局搜索"
>
<template #prefix>
<Search />
</template>
</Input>
<!-- 中间:多页签(与顶栏合成一条,不再单独一条) -->
<div class="fms-topbar-tabs">
<AppTabs />
</div>
<!-- 右侧:语言 / 主题色板 / 深浅色 / 通知 / 用户 -->
@@ -212,7 +203,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
</template>
<style lang="scss" scoped>
/* ---- 顶栏 ---- */
/* ---- 顶栏(已合并页签,白色浮条,带框架阴影) ---- */
.fms-topbar {
display: flex;
align-items: center;
@@ -220,10 +211,9 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
gap: 8px;
height: 40px;
padding: 0 12px;
/* 顶栏独立成一块:与 .fms-main 同款白底 + 圆角 + 同款投影,与下方白卡并列分层 */
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
box-shadow: var(--fms-frame-shadow);
}
.fms-topbar-left,
@@ -241,6 +231,22 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
margin-left: auto;
}
/* 中间页签区:填入剩余宽度,高度撑满顶栏 */
.fms-topbar-tabs {
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
}
/* AppTabs 嵌入顶栏时:填满容器高度,去掉独立底边线和外侧 padding */
.fms-topbar-tabs :deep(.fms-tabs) {
height: 100%;
padding: 0 4px;
border-bottom: none;
box-shadow: none;
}
/* 顶栏图标按钮:复用组件库 Button type="ghost"(透明底/无边框/hover 高亮)。
用 .fms-topbar 前缀提高特异性,覆盖 ghost 的横向 padding / 次要文字色 / 图标尺寸,
使其保持 32×32 正方形、主文字色图标。hover 沿用 ghost 的 --fms-secondary。 */
@@ -275,52 +281,6 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
}
}
/* 全局搜索框:基于组件库 Input,disabled 仅用于「不可聚焦/不可编辑」,
视觉上用 :deep() 还原为浅灰装饰框(覆盖库默认的禁用灰底 + not-allowed 光标) */
.fms-topbar-left .fms-search {
flex-shrink: 0;
width: 220px;
height: 28px;
min-height: 28px;
padding: 0 8px;
background: color-mix(in srgb, var(--fms-secondary-hover) 50%, var(--fms-card));
cursor: pointer;
transition: border-color 150ms ease;
/* 覆盖组件库禁用态:还原正常外观 + 手型光标 */
&.input-disabled {
background: color-mix(in srgb, var(--fms-secondary-hover) 50%, var(--fms-card));
border-color: var(--fms-border);
color: var(--fms-text-secondary);
cursor: pointer;
}
/* 前缀搜索图标尺寸 */
:deep(.input-prefix) {
margin-right: 6px;
}
:deep(.input-prefix svg) {
width: 16px;
height: 16px;
}
/* 内部输入框 */
:deep(.input) {
font-size: 12px;
cursor: pointer;
color: var(--fms-text);
&::placeholder {
color: var(--fms-text-secondary);
}
&:disabled {
cursor: pointer;
}
}
}
.fms-separator {
flex-shrink: 0;
width: 1px;
@@ -218,12 +218,12 @@ function onToggle() {
min-height: 28px;
}
/* 激活高亮:白底 + shadow-sm + 深色文字(对齐 data-[active=true]) */
/* 激活高亮:主题色浅底 + 主题色文字(白色侧栏上可见) */
.fms-nav-link.is-active {
background: var(--fms-card);
color: var(--fms-text);
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
color: var(--fms-primary);
font-weight: 500;
box-shadow: var(--fms-shadow);
box-shadow: none;
}
/* ---- 折叠态:仅图标,居中展示(对齐 size-8 icon 模式) ---- */
+8
View File
@@ -43,6 +43,11 @@
--fms-control-radius: 6px;
--fms-control-size: 16px;
/* 框架阴影(顶栏/侧栏):轻量双层、紧贴、扩散小 */
--fms-frame-shadow:
0 1px 2px rgba(30, 45, 64, 0.06),
0 3px 8px rgba(30, 45, 64, 0.08);
/* 开关(switch)尺寸(不随主题变化) */
--fms-switch-width: 32px;
--fms-switch-height: 18px;
@@ -148,4 +153,7 @@
/* 阴影(深色下加重投影) */
--fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.5);
--fms-frame-shadow:
0 1px 2px rgba(0, 0, 0, 0.4),
0 3px 8px rgba(0, 0, 0, 0.3);
}
File diff suppressed because it is too large. Load diff
@@ -1451,7 +1451,6 @@ loadMenuTree()
</template>
<template #rightExtra>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="busy || !selectedModule" @click="reloadModule">
<RefreshCw :size="14" /> 重载
</Button>
@@ -1686,7 +1685,6 @@ loadMenuTree()
</template>
<template #rightExtra>
<div class="mm-header__actions">
<span class="mm-header__hint">Ctrl + S 保存</span>
<Button type="outline" :disabled="menuSaving" @click="reloadMenu">
<RefreshCw :size="14" /> 重载
</Button>
@@ -1945,13 +1943,6 @@ loadMenuTree()
flex: none;
}
.mm-header__hint {
flex: none;
color: var(--fms-text-secondary);
font-size: 12px;
white-space: nowrap;
}
/* ---- 右侧:配置工作区 ---- */
.mm-body__inner {
display: flex;
@@ -1150,8 +1150,8 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
expect(cards.length).toBe(3)
expect(wrapper.text()).toContain('基础信息')
expect(wrapper.text()).toContain('未分组')
// 属性面板初始未选中字段
expect(wrapper.text()).toContain('点击画布中的字段卡片')
// 属性面板初始未选中字段(子标题显示"未选中")
expect(wrapper.find('.mm-fdesign__inspector .mm-fdesign__subhead').text()).toContain('未选中')
// 数据视图表格默认隐藏
expect(wrapper.find('.mm-edit__data').attributes('style')).toContain('display: none')
})
@@ -1187,12 +1187,13 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
expect(card2.classes()).toContain('is-selected')
})
it('仅桌面端设计:无设备切换按钮,画布固定桌面端 24 栅格', async () => {
it('表单设计不显示设备切换和画布提示文案', async () => {
const wrapper = mountEdit()
await nextTick()
expect(wrapper.find('button[data-device]').exists()).toBe(false)
expect(wrapper.find('.mm-fdesign__sheet').classes()).not.toContain('is-mobile')
expect(wrapper.text()).toContain('画布 · 桌面端 24 栅格')
expect(wrapper.text()).not.toContain('画布 · 桌面端 24 栅格')
expect(wrapper.text()).not.toContain('从左侧字段库点击或拖拽,即可添加表单项')
expect(wrapper.text()).not.toContain('移动端预览')
})
@@ -1225,17 +1226,12 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
})
it('属性面板:修改排序号与开关写入草稿并 emit update', async () => {
it('属性面板:开关写入草稿并 emit update', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f3"]').trigger('click')
await nextTick()
const xhInput = wrapper.find('#mm-fdesign-xh')
await xhInput.setValue('35')
await xhInput.trigger('change')
await nextTick()
// 必填开关:初始关闭(f3 required=0)→ 点击打开
const requiredToggle = wrapper.findAll('.mm-fdesign__toggle')[0]
expect(requiredToggle.classes()).not.toContain('is-on')
@@ -1247,7 +1243,6 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
expect(updates).toBeTruthy()
const last = updates[updates.length - 1][0]
const row = last.find((r) => r.b_id === 'e3')
expect(row.b_xh).toBe(35)
expect(row.b_required).toBe(1)
})
@@ -1846,7 +1841,7 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
await wrapper.find('.mm-fdesign__sheet').trigger('click')
await nextTick()
expect(wrapper.find('.mm-fdesign__field.is-selected').exists()).toBe(false)
expect(wrapper.find('.mm-fdesign__inspector').text()).toContain('点击画布中的字段卡片')
expect(wrapper.find('.mm-fdesign__inspector .mm-fdesign__subhead').text()).toContain('未选中')
})
// ================= 拖拽边界全量回归(补齐同行内调序 / 跨组 / 源行回收等场景) =================
@@ -2182,7 +2177,7 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
wrapper.unmount() // 移除 window keydown 监听
})
it('头部撤销 / 重做按钮:删除字段后可撤销恢复、重做再删除', async () => {
it('删除字段后可撤销恢复、重做再删除(Ctrl+Z / Ctrl+Shift+Z)', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__field[data-field-id="f1"]').trigger('click')
@@ -2191,13 +2186,16 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
await wrapper.find('button[aria-label="撤销"]').trigger('click')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }))
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(3)
await wrapper.find('button[aria-label="重做"]').trigger('click')
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, shiftKey: true, bubbles: true }),
)
await nextTick()
expect(wrapper.findAll('.mm-fdesign__field').length).toBe(2)
wrapper.unmount() // 移除 window keydown 监听
})
it('工具栏上移 / 下移:整行与相邻行整体交换', async () => {
@@ -2359,17 +2357,6 @@ describe('ModuleEditConfigPanel(表单设计视图)', () => {
expect(wrapper.findAll('.mm-fdesign__lib-item').length).toBe(6) // 4 字段 + 2 布局组件
})
it('分组方向按钮:切换单个分组并 emit updateGroups', async () => {
const wrapper = mountEdit()
await nextTick()
await wrapper.find('.mm-fdesign__section-dir').trigger('click')
await nextTick()
const updates = wrapper.emitted('updateGroups')
expect(updates).toBeTruthy()
const updatedGroups = updates.at(-1)[0]
expect(updatedGroups.find((g) => g.b_id === 'g1').b_layout_direction).toBe('horizontal')
})
it('智能偏向判定:按比例最近边缘,鼠标偏向哪条边就插到哪边', async () => {
const rows = [mkRow('a1', 'f1', 'g1', 24, 10), mkRow('a2', 'f2', 'g1', 24, 20)]
const wrapper = mountEdit({ editConfig: rows })
@@ -2752,8 +2739,8 @@ describe('ModuleEditConfigPanel(子表表格设计)', () => {
editConfig: [tableRows[0]],
})
await nextTick()
// 拖拽前:空分组显示拖入提示
expect(wrapper.find('.mm-tdesign__section-drop-hint').text()).toContain('空分组')
// 拖拽前:空分组渲染拖入占位
expect(wrapper.find('.mm-tdesign__section-drop-hint').exists()).toBe(true)
// 模拟拖拽开始(dragPayload 置位)→ 提示隐藏
const list = columnDraggableOf(wrapper)
list.vm.$emit('start', { item: itemEl('f1') })
@@ -2796,7 +2783,8 @@ describe('ModuleEditConfigPanel(子表表格设计)', () => {
await nextTick()
const titles = wrapper.findAll('.mm-tdesign__section-title').map((t) => t.text())
expect(titles.some((t) => t.includes('新分组'))).toBe(true)
expect(wrapper.find('.mm-tdesign__section-drop-hint').text()).toContain('空分组')
// 新空分区渲染拖入占位
expect(wrapper.find('.mm-tdesign__section-drop-hint').exists()).toBe(true)
})
it('字段库已添加字段标记 data-added(拖拽被 filter 拦截,已添加字段不可再拖)', async () => {
-319
View File
@@ -1,319 +0,0 @@
# FMS Vue AI 组件库开发规范
本文档是 AI 修改或新增 `fms-vue` UI 组件时的默认约束。目标是保持组件库稳定、轻量、可预测,并把性能放在功能完成之前。
## 1. 优先级
按以下顺序做技术决策:
1. 正确性和现有 API 行为
2. 性能和资源释放
3. 组件写法一致
4. 可访问性和交互反馈
5. 视觉细节
当需求与现有约定冲突时,优先保持组件库的一致性;确需破坏 API 时,必须在回复中明确说明。
## 2. AI 工作边界
按任务类型区分默认行为:
**业务开发任务**(新增/修改组件、页面、store、服务等):
- 默认只阅读、修改和静态检查与需求相关的源码。
- 不主动执行构建、全量测试、预览服务或大范围格式化。
- 只有用户明确要求时,才执行 `pnpm build`、测试命令或启动开发服务。
- 改动涉及已测对象时,至少运行一次 `pnpm test:file <相关测试文件>` 验证不回归;但不应在未获许可时跑全量 `pnpm test` 或 `pnpm test:coverage`。
- 不修改用户已有的无关改动,不删除生成目录,不重置工作区。
- 修改前先检查目标文件和相邻组件,避免重复抽象或引入第二套写法。
- 完成后只报告实际修改内容和静态检查结果;没有执行构建或测试时必须明确说明。
**测试任务**(编写/补齐/运行测试,或用户明确说"测试"):
- 以 `tests/README.md` 为唯一权威约定:目录与命名、编写模板、mock 接缝、每个对象的测试清单、覆盖率门槛。
- 必须实际执行测试命令并报告结果(`pnpm test` / `pnpm test:file <目标>` / `pnpm test:coverage`),不允许只做静态检查。
- 默认目标是:用例全绿 + `pnpm test:coverage` 达到 60% 门槛(或明确报告缺口与下一步)。
- 遵循 `tests/README.md` 第 7 节"AI 工作流"的固定步骤。
任务类型不明确时,向用户确认;用户说"测试""补测试""覆盖率"等词时按测试任务处理。
### 2.1 参考组件库
- 新增或重构组件前,先参考 `fms/参考组件库` 中已有组件的功能设计、交互状态和 UI 细节;从 `fms-vue` 目录查看时路径为 `../参考组件库`。
- 参考重点包括:控件状态、键盘操作、焦点样式、间距层级、禁用态、错误态、加载态和响应式行为。
- 参考组件库用于借鉴设计和验证常见交互,不直接复制整套实现;最终代码必须遵守本项目的 Vue、CSS token、Lucide 图标和性能规则。
- 如果本项目的现有 API 或视觉 token 与参考组件库不同,保持本项目约定,并在必要时说明差异原因。
- AI 在开始编码前,应先搜索参考组件库是否已有相同或相近能力,避免重复设计组件行为。
## 3. 目录和文件结构
组件统一放在 `src/components/ui` 下,每个组件使用独立目录:
```text
src/components/ui/
input/
input.vue
index.scss
button/
button.vue
index.scss
utils/
dom.js
number.js
```
约定:
- 组件入口使用小写文件名,例如 `input.vue`、`button.vue`。
- 组件样式放在同目录 `index.scss`,组件内通过 `@use './index.scss'` 引入。
- 公用方法只放在 `src/components/ui/utils`,按职责拆分文件。
- `utils` 只能放无副作用、可复用的纯函数或明确的 DOM 辅助函数,不依赖具体组件状态。
- 工具函数禁止反向导入组件,避免循环依赖。
- 新增目录时不额外创建 barrel index,除非项目已有明确的统一导出入口。
## 4. Vue 组件统一写法
### 4.1 基础结构
使用 Vue 3 `<script setup>`,顺序保持一致:
```vue
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const classes = computed(() => ['component-name'])
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>
```
### 4.2 Props 和事件
- 每个 Prop 必须有明确类型和默认值,枚举值必须提供 `validator`。
- Prop 名称使用 camelCase,DOM 属性在模板中使用 kebab-case。
- 双向值统一使用 `modelValue` 和 `update:modelValue`。
- 事件名使用小写短语,例如 `focus`、`blur`、`clear`、`change`。
- 原生事件转发前,先更新组件内部状态,再 `emit` 给父组件。
- 不要把业务用的枚举值直接传给原生 DOM。例如业务 `type="default"` 应映射为原生 `type="text"`。
- `Input` 的 `type` 当前只允许 `default`、`password`、`number`、`textarea`;`number` 默认只接受 ASCII 数字和一个小数点。输入时保留 `12.` 等字符串中间态,触发 `change` 或失焦时使用 `Number()` 转为数字;空值仍保持 `''`,避免被转换为 `0`。
- `type="number"` 的外部初始值应为 `number` 或 `''`。后端返回的数字字符串应在业务数据入口统一转换,不在每个输入组件挂载或渲染时重复清洗;字符串只作为用户编辑小数时的临时状态。
- 只保留当前确认的 API,不为旧 API 增加兼容分支。兼容会增加条件判断、文档复杂度和维护成本。
### 4.3 响应式状态
- 能从 Props 或状态直接计算出的值使用 `computed`,不要复制到另一个 `ref`。
- 只在确有副作用或需要同步 DOM 时使用 `watch`。
- `watch` 必须限定明确依赖,禁止深度监听大对象来解决局部问题。
- 临时交互状态使用普通变量,不要为了拖拽起点、动画帧 ID 等数据创建响应式变量。
- 组件卸载时清理所有 `document`、`window` 监听器、计时器、动画帧和全局样式修改。
### 4.4 组件内代码分块和注释
一个组件支持多个模式时,必须按职责分块书写。不要把 `textarea`、`password`、`number` 的状态、计算属性和事件处理交错放在一起。
统一使用以下顺序:
```text
1. imports
2. props / emits / slots
3. 公共状态和公共 computed
4. 公共方法
5. default 专属逻辑
6. password 专属逻辑
7. number 专属逻辑
8. textarea 专属逻辑
9. 生命周期和监听器
10. template
11. style
```
推荐使用清晰的分隔注释:
```vue
<script setup>
// imports
// props / emits / slots
/* ---------- 公共状态 ---------- */
const focused = ref(false)
const hasValue = computed(() => ...)
/* ---------- 公共事件 ---------- */
function onFocus(event) { ... }
function onBlur(event) { ... }
/* ---------- password 模式 ---------- */
const revealed = ref(false)
function toggleReveal() { ... }
/* ---------- number 模式 ---------- */
function sanitizeNumberInput(target) { ... }
/* ---------- textarea 模式 ---------- */
const textareaEl = ref(null)
function onResizeStart(event) { ... }
/* ---------- 生命周期 ---------- */
onMounted(() => { ... })
onBeforeUnmount(() => { ... })
</script>
```
分块规则:
- 公共逻辑放在所有模式专属逻辑之前,公共逻辑不能依赖某个模式的 DOM 引用。
- 每个模式只维护自己的状态、computed、事件和清理逻辑;跨模式判断集中在 `isPassword`、`isNumber`、`isTextarea` 等少量 computed 中。
- 一个模式的事件函数不要混入另一个模式的分支。需要共用时,提取为公共函数或 `components/ui/utils` 工具函数。
- 生命周期统一放在逻辑分块之后;监听器的注册和清理必须在同一分块或同一生命周期区域中成对出现。
- 模板也按公共结构、单行输入、密码控件、数字控件、文本域和辅助操作的顺序排列,并与脚本分块顺序对应。
- 同一个模式的代码尽量连续出现,避免在文件顶部声明状态、文件底部再实现对应事件。
注释规则:
- 每个有独立职责的代码块必须有一个短标题注释。
- 注释解释“为什么这样做”或边界条件,不重复描述代码字面行为。
- 高频交互、DOM 布局读取、浏览器兼容处理、输入过滤和资源清理必须写明原因。
- 简单的赋值、显而易见的 computed 和普通事件转发不写逐行注释,避免噪声。
- 注释语言跟随项目现有语言,当前组件库使用中文注释;函数名和变量名仍使用英文。
- 不使用无信息量的注释,例如“定义变量”“处理点击”“返回结果”。
Input 这类多模式组件的最低分块要求:
- 公共:Props、事件、焦点状态、清除、字数统计和 class 计算。
- `password`:显隐状态、显隐切换和密码图标。
- `number`:小数键盘提示、非法字符过滤、单个小数点约束、光标修正和数值回显。
- `textarea`:默认高度测量、拖拽状态、动画帧合并、键盘调整和卸载清理。
模式专属逻辑即使超过约 30 行,也应优先继续保留在当前组件对应的分块内,保证该模式的状态、事件和清理逻辑集中可读。只有被两个或以上组件复用的公共逻辑,才考虑抽到 `components/ui/utils`;不要为模式专属逻辑单独创建 composable 或工具函数。
## 5. 性能优先规则
### 5.1 渲染和更新
- 模板中避免调用会创建新对象或执行计算的函数;预先使用 `computed` 或普通变量准备结果。
- 高频事件(`pointermove`、`mousemove`、`scroll`、`resize`)必须节流、合并或使用 `requestAnimationFrame`。
- 高频事件中不要同时读取布局和写入样式,避免 layout thrashing。先集中读取,再在下一帧写入。
- 拖拽、滚动等连续交互优先直接更新必要的 DOM 样式,只有需要驱动模板或无障碍属性时才更新响应式状态。
- 不要在每个字符输入时执行无关的深度计算、网络请求或全局状态更新。
- 大列表、复杂内容和重组件不应在输入组件内部同步渲染;需要时由上层使用虚拟列表或异步加载。
### 5.2 DOM 和事件
- `offsetHeight`、`getBoundingClientRect`、`getComputedStyle` 等布局读取应尽量只发生在初始化、开始拖拽或明确的布局变化时。
- 监听器只在需要交互时注册,交互结束立即移除,不要长期注册全局监听器。
- 触控拖拽设置 `touch-action`,避免浏览器默认手势与组件逻辑竞争。
- 事件处理函数保持短小,只做状态更新、必要的 DOM 操作和事件派发。
- 能使用事件委托时不要为大量重复节点分别注册监听器。
### 5.3 资源和依赖
- 优先复用已有依赖和 Lucide 图标,不引入只服务于一个小功能的新依赖。
- 不在组件中内联大段 SVG、图片或重复配置。
- 图标按需导入,不导入整个图标包。
- 不添加无意义动画。动画必须有交互价值,并支持 `prefers-reduced-motion`。
## 6. 公用方法抽取规则
当相同逻辑在两个或以上组件中出现,或逻辑本身独立且需要单元验证时,考虑抽到 `src/components/ui/utils`。
推荐的工具函数类型:
```js
// src/components/ui/utils/dom.js
export function restoreStyle(element, property, value) {
if (element) element.style[property] = value
}
// src/components/ui/utils/number.js
export function clamp(value, min, max = Number.POSITIVE_INFINITY) {
return Math.min(max, Math.max(min, value))
}
```
抽取要求:
- 函数输入和返回值清晰,优先使用纯函数。
- 不在工具函数中读取组件 Props、访问 Pinia 或隐式依赖全局变量。
- DOM 工具函数接收元素作为参数,不自行查询整个文档。
- 工具函数不能为了减少几行代码而增加抽象层;重复逻辑、边界处理或跨组件复用才值得抽取。
- 抽取后组件仍需保留业务语义,不能把所有逻辑堆进一个 `utils.js`。
## 7. CSS 最小化规则
- 优先复用现有 CSS 变量:颜色、间距、圆角、字号、阴影和控件高度必须使用 `--fms-*` token。
- 优先使用父级布局解决间距和对齐,避免给每个子元素增加独立定位规则。
- 同一状态只保留一套选择器,不重复写 hover、focus、disabled 样式。
- 不使用 `!important`,除非明确处理第三方样式且无法通过组件边界解决。
- 不为单次布局引入复杂计算、过多绝对定位或额外包裹层。
- 图标尺寸使用 `1em` 或现有控件尺寸,不在每个组件中重复定义固定 SVG 尺寸。
- 输入类组件的内部控件默认 `padding: 0`,由外层统一控制空间,避免出现叠加留白。
- 过渡效果限定在颜色、边框和阴影等低成本属性;避免持续动画 `width`、`height`、`top`、`left`。
- 需要响应式布局时使用现有断点和流式布局,不新增只服务单个组件的断点。
### 7.1 尺寸与 token 规范
- 组件不提供 `size`(`small` / `large` / `sm` / `lg` 等)尺寸枚举,统一保持默认大小,避免组件 API 膨胀。
- 控件尺寸、间距、圆角、字号等样式优先复用 `src/theme/tokens.css` 中的 `--fms-*` token,不在组件 `index.scss` 中硬编码具体数值。
- 确实缺少的尺寸或样式 token,统一新增到 `tokens.css`(`--fms-*` 命名,与主题无关的尺寸只写在 `:root`),再由组件引用,不在单个组件样式里私自定义固定大小。
- 现有 token 无法直接表达的比例关系(如 switch 滑块位移 = 轨道宽 - 滑块直径 - 2 × 边距),优先用 `calc()` 派生,而不是另设一个重复的尺寸 token。
### 7.2 浮层组件宽度策略
使用 `Popover` 派生浮层类组件(Select、DatePicker、RangePicker 等)时,浮层宽度按以下规则处理,保持视觉统一与可读性:
- 浮层默认跟随触发器宽度:为 Popover 传 `follow-trigger-width`,让下拉/面板与触发框严格等宽,视觉统一。不要靠内容宽度撑开浮层,除非该组件的内容宽度是固定的(如纯提示型 Tooltip)。
- 内容有结构性宽度下限时(如日历 7 列日期、表格列等),`min-width` 加在**组件根元素**上,而不是浮层或面板上。根元素被撑到 ≥ min-width 后,浮层跟随根元素自然达到下限,同时保持等宽。
- 不要在浮层面板上设 `min-width`,否则会出现浮层比触发器宽、上下不对齐的视觉断裂。
- 不设置最大宽度:容器过宽时允许浮层随之变宽,内容超宽属于无法控制的范围,交由定位逻辑的 shift 兜底。
- 面板内部自适应区域用 `flex: 1 1 auto` / `width: 100%` 填满剩余空间,固定尺寸区域(如时间列)保持固定宽度,两者搭配让面板在任意宽度下不塌缩。
- 参考实现:`Select`(根 `min-width: 200px`)与 `DatePicker` / `RangePicker`(根 `min-width: 240px`,对齐日历 7 列可读性)。
## 8. 交互和可访问性底线
- 图标按钮必须有 `aria-label` 或可见文本。
- 键盘可操作控件必须有清晰的 `:focus-visible` 样式。
- 自定义拖拽、切换和滑块控件应提供对应的 ARIA role 和键盘操作。
- 禁用和只读状态必须同时反映到原生属性和视觉状态。
- 不要只依赖颜色表达错误、警告或选中状态。
- 交互命中区域不能因为视觉压缩而无法点击;需要紧贴边角时,通过透明命中区域保留可用尺寸。
## 9. 修改流程
AI 每次修改 UI 组件时按以下流程执行:
1. 阅读目标组件、相邻组件和对应样式文件。
2. 确认 Props、事件、插槽和现有 CSS token,避免重复定义。
3. 先实现最小可用逻辑,再处理边界状态和可访问性。
4. 高频交互先设计事件生命周期和清理路径,再编写模板和样式。
5. 检查是否存在可复用方法;符合条件时抽到 `components/ui/utils`。
6. 只做相关文件的静态检查和格式整理,不主动构建或全量测试;涉及已测对象时运行 `pnpm test:file <相关测试>` 验证不回归(测试任务按 `tests/README.md` 执行)。
7. 回复中列出变更文件、性能处理、未执行的构建/测试,以及仍存在的已知风险。
## 10. 交付检查清单
- [ ] API 是否只包含需求明确要求的 Props 和事件?
- [ ] 枚举 Props 是否有 `validator`?
- [ ] 是否避免了旧 API 兼容分支和重复状态?
- [ ] 高频事件是否节流或使用 `requestAnimationFrame`?
- [ ] 是否避免在事件循环中反复读取布局?
- [ ] 所有全局监听器、计时器和动画帧是否在结束和卸载时清理?
- [ ] 公用逻辑是否按规则放在 `src/components/ui/utils`?
- [ ] CSS 是否复用 token,且没有无必要的嵌套和重复规则?
- [ ] 是否未添加 `size` 属性,尺寸/大小均复用 `--fms-*` token(缺失时已补充到 `tokens.css`)?
- [ ] 图标按钮、焦点状态和键盘操作是否可用?
- [ ] 是否遵守“业务开发默认不主动构建和全量测试、涉及已测对象须跑相关测试”的工作边界,并在结果中说明?(测试任务遵循 `tests/README.md`)