diff --git a/code/fms/.codebuddy/plans/button-enhance_280ee511.md b/code/fms/.codebuddy/plans/button-enhance_280ee511.md new file mode 100644 index 00000000..e55490af --- /dev/null +++ b/code/fms/.codebuddy/plans/button-enhance_280ee511.md @@ -0,0 +1,100 @@ +--- +name: button-enhance +overview: 为 Button 组件补齐行为层能力:nativeType、disabled、loading、size、block 五个 prop,配套样式与 token,并更新 App.vue 展示页示例。 +todos: + - id: add-size-tokens + content: 在 tokens.css 新增 sm/lg 尺寸、内边距与字号 token + status: completed + - id: button-props + content: 改造 button.vue:新增 nativeType、disabled、loading、size、block 五个 prop 与 spinner 模板 + status: completed + dependencies: + - add-size-tokens + - id: button-styles + content: 扩展 index.scss:实现 size、block、disabled、loading 与 spinner 动画样式 + status: completed + dependencies: + - add-size-tokens + - id: app-demo + content: 更新 App.vue 展示页补充禁用、加载、尺寸、通栏示例 + status: completed + dependencies: + - button-props + - button-styles +--- + +## 产品概述 + +为现有 Button 组件补齐行为层能力,使其从"纯视觉变体"升级为可直接用于表单和异步场景的交互组件,同时保持当前克制、token 驱动的设计语言。 + +## 核心功能 + +- **原生 type 控制**:新增 `nativeType` prop,默认 `button`,避免放入表单时误触发表单提交 +- **禁用态**:新增 `disabled` prop,禁用点击、hover/active 效果,光标提示 `not-allowed` +- **加载态**:新增 `loading` prop,显示内联 SVG spinner,标记 `aria-busy`,加载期间禁止交互且布局宽度不跳动 +- **尺寸三档**:新增 `size` prop(small / default / large),通过 token 驱动高度、内边距与字号 +- **通栏模式**:新增 `block` prop,按钮占满整行宽度,适配表单底部操作区 + +## 技术栈 + +- Vue 3 `
diff --git a/code/fms/fms-vue/package.json b/code/fms/fms-vue/package.json index a5335cc0..f9a432c5 100644 --- a/code/fms/fms-vue/package.json +++ b/code/fms/fms-vue/package.json @@ -14,8 +14,10 @@ "check": "pnpm lint && pnpm fmt:check && pnpm build" }, "dependencies": { - "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@fontsource/noto-sans-sc": "^5.3.0", + "@lucide/vue": "^1.31.0", "axios": "^1.19.0", + "dayjs": "^1.11.21", "nprogress": "^0.2.0", "pinia": "^4.0.3", "pinia-plugin-persistedstate": "^4.7.1", diff --git a/code/fms/fms-vue/pnpm-lock.yaml b/code/fms/fms-vue/pnpm-lock.yaml index 3ed49e77..ba7083a0 100644 --- a/code/fms/fms-vue/pnpm-lock.yaml +++ b/code/fms/fms-vue/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: - '@fontsource-variable/noto-sans-sc': + '@fontsource/noto-sans-sc': specifier: ^5.3.0 version: 5.3.0 + '@lucide/vue': + specifier: ^1.31.0 + version: 1.31.0(vue@3.5.41) axios: specifier: ^1.19.0 version: 1.19.0 + dayjs: + specifier: ^1.11.21 + version: 1.11.21 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -92,8 +98,8 @@ packages: resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} - '@fontsource-variable/noto-sans-sc@5.3.0': - resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==} + '@fontsource/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -111,6 +117,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lucide/vue@1.31.0': + resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==} + peerDependencies: + vue: '>=3.0.1' + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -638,6 +649,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1156,7 +1170,7 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 - '@fontsource-variable/noto-sans-sc@5.3.0': {} + '@fontsource/noto-sans-sc@5.3.0': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -1177,6 +1191,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lucide/vue@1.31.0(vue@3.5.41)': + dependencies: + vue: 3.5.41 + '@oxc-project/types@0.144.0': {} '@oxfmt/binding-android-arm-eabi@0.63.0': @@ -1533,6 +1551,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/code/fms/fms-vue/pnpm-workspace.yaml b/code/fms/fms-vue/pnpm-workspace.yaml new file mode 100644 index 00000000..620a7bb7 --- /dev/null +++ b/code/fms/fms-vue/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + '@parcel/watcher': true diff --git a/code/fms/fms-vue/src/App.vue b/code/fms/fms-vue/src/App.vue index fa533fd5..0f05e9ee 100644 --- a/code/fms/fms-vue/src/App.vue +++ b/code/fms/fms-vue/src/App.vue @@ -1,107 +1,7 @@ - - diff --git a/code/fms/fms-vue/src/components/ui/ColorThemePicker.vue b/code/fms/fms-vue/src/components/ui/ColorThemePicker.vue index 7a2ce6b5..97b4c7eb 100644 --- a/code/fms/fms-vue/src/components/ui/ColorThemePicker.vue +++ b/code/fms/fms-vue/src/components/ui/ColorThemePicker.vue @@ -1,133 +1,98 @@ diff --git a/code/fms/fms-vue/src/components/ui/ThemeToggle.vue b/code/fms/fms-vue/src/components/ui/ThemeToggle.vue index f40424ca..b9c80321 100644 --- a/code/fms/fms-vue/src/components/ui/ThemeToggle.vue +++ b/code/fms/fms-vue/src/components/ui/ThemeToggle.vue @@ -1,18 +1,61 @@ + + diff --git a/code/fms/fms-vue/src/components/ui/button-group/button-group-separator.vue b/code/fms/fms-vue/src/components/ui/button-group/button-group-separator.vue new file mode 100644 index 00000000..64f71bbb --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/button-group/button-group-separator.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/button-group/button-group.vue b/code/fms/fms-vue/src/components/ui/button-group/button-group.vue new file mode 100644 index 00000000..61a45623 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/button-group/button-group.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/button/button.vue b/code/fms/fms-vue/src/components/ui/button/button.vue index ec6d6dc3..c7a7a57f 100644 --- a/code/fms/fms-vue/src/components/ui/button/button.vue +++ b/code/fms/fms-vue/src/components/ui/button/button.vue @@ -1,22 +1,63 @@ - diff --git a/code/fms/fms-vue/src/components/ui/button/index.scss b/code/fms/fms-vue/src/components/ui/button/index.scss index d1f10152..e1cbd2e7 100644 --- a/code/fms/fms-vue/src/components/ui/button/index.scss +++ b/code/fms/fms-vue/src/components/ui/button/index.scss @@ -2,101 +2,147 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 0.5rem; - white-space: nowrap; - border: 1px solid transparent; - border-radius: var(--radius); + gap: 6px; + min-height: var(--fms-control-height); + + /* 按钮内图标跟随字号缩放 */ + :deep(svg) { + width: 1em; + height: 1em; + } + padding: 0 var(--fms-control-padding-x); + font-size: var(--fms-font-size); font-weight: 500; - line-height: 1; - height: 2.5rem; - padding: 0 1rem; - font-size: 0.875rem; - outline: none; + font-family: inherit; + line-height: var(--fms-line-height); + border: 1px solid transparent; + border-radius: var(--fms-control-radius); + cursor: pointer; transition: - color 120ms ease, - background-color 120ms ease, - border-color 120ms ease, - transform 80ms ease; + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease, + transform 150ms ease; } -.button:active:not(:disabled) { - transform: scale(0.985); +.button:active { + transform: scale(0.97); } +.button:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 通栏 */ +.button-block { + display: flex; + width: 100%; +} + +/* 主要操作 —— 跟随主色 */ .button-default { - color: var(--primary-foreground); - background: var(--primary); + background: var(--fms-primary); + color: var(--fms-primary-text); } -.button-default:hover { - background: var(--primary-hover); +.button-default:hover:not(:disabled) { + background: var(--fms-primary-hover); } .button-default:active { - background: var(--primary-active); + background: var(--fms-primary-active); } +/* 次要操作 */ .button-secondary { - color: var(--secondary-foreground); - background: var(--secondary); + background: var(--fms-secondary-hover); + color: var(--fms-text); } -.button-secondary:hover, -.button-ghost:hover { - background: var(--accent); - color: var(--accent-foreground); -} - -.button-secondary:active, -.button-ghost:active { - background: color-mix(in srgb, var(--accent) 88%, var(--foreground)); - color: var(--accent-foreground); +.button-secondary:hover:not(:disabled) { + background: color-mix(in srgb, var(--fms-secondary-hover) 92%, var(--fms-text)); } +/* 描边按钮 */ .button-outline { - color: var(--foreground); - border-color: var(--input); - background: var(--background); + background: transparent; + border-color: var(--fms-border); + color: var(--fms-text); } -.button-outline:hover { - color: var(--accent-foreground); - background: var(--accent); -} - -.button-outline:active { - color: var(--accent-foreground); - background: color-mix(in srgb, var(--accent) 88%, var(--foreground)); +.button-outline:hover:not(:disabled) { + background: var(--fms-secondary-hover); } +/* 低强调 */ .button-ghost { - color: var(--foreground); background: transparent; + color: var(--fms-text-secondary); } -.button-destructive { - color: var(--destructive-foreground); - background: var(--destructive); +.button-ghost:hover:not(:disabled) { + background: var(--fms-secondary); + color: var(--fms-text); } -.button-destructive:hover { - background: color-mix(in srgb, var(--destructive) 90%, var(--background)); +/* 危险操作 */ +.button-danger { + background: var(--fms-danger); + color: var(--fms-danger-text); } -.button-destructive:active { - background: color-mix(in srgb, var(--destructive) 80%, var(--background)); +.button-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--fms-danger) 88%, #000); } +/* 链接按钮 */ .button-link { - color: var(--primary); background: transparent; - text-underline-offset: 4px; + color: var(--fms-primary); + padding: 0 4px; } -.button-link:hover { +.button-link:hover:not(:disabled) { + color: var(--fms-primary-hover); text-decoration: underline; } -.button-link:active { - color: var(--primary-active); +/* 禁用态 —— 参考 shadcn-vue:保留原配色,整体降透明度,悬停显示禁用光标 */ +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 加载态 —— spinner 在前,隐藏自带图标 */ +.button-loading { + cursor: default; +} + +.button-loading :deep(svg):not(.button-spinner) { + display: none; +} + +:deep(.button-spinner) { + width: 1em; + height: 1em; + flex-shrink: 0; + animation: button-spin 800ms linear infinite; +} + +@keyframes button-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } + + .button-spinner, + :deep(.button-spinner) { + animation: none; + } } diff --git a/code/fms/fms-vue/src/components/ui/checkbox/checkbox.vue b/code/fms/fms-vue/src/components/ui/checkbox/checkbox.vue new file mode 100644 index 00000000..437e89e5 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/checkbox/checkbox.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/checkbox/index.scss b/code/fms/fms-vue/src/components/ui/checkbox/index.scss new file mode 100644 index 00000000..fced7378 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/checkbox/index.scss @@ -0,0 +1,78 @@ +.checkbox { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + cursor: pointer; + user-select: none; +} + +/* 视觉隐藏原生 input,保留可聚焦与同 name 键盘导航 */ +.checkbox-input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* 视觉框:尺寸与圆角由 token 控制,图标随字号缩放 */ +.checkbox-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--fms-control-size); + height: var(--fms-control-size); + flex-shrink: 0; + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + background: var(--fms-card); + color: var(--fms-primary-text); + /* 勾选图标字号 = 选框边长 × 0.75 */ + font-size: calc(var(--fms-control-size) * 0.75); + transition: + background-color 150ms ease, + border-color 150ms ease; + + :deep(svg) { + width: 1em; + height: 1em; + } +} + +/* 未选中悬停:边框贴近主色 */ +.checkbox:hover:not(.checkbox-disabled) .checkbox-box { + border-color: var(--fms-primary); +} + +/* 选中 / 半选:主色底 + 白色图标 */ +.checkbox-checked .checkbox-box, +.checkbox-indeterminate .checkbox-box { + background: var(--fms-primary); + border-color: var(--fms-primary); +} + +/* 键盘焦点投射到视觉框 */ +.checkbox-input:focus-visible + .checkbox-box { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 禁用态:保留原配色整体降透明,与 button 保持一致 */ +.checkbox-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@media (prefers-reduced-motion: reduce) { + .checkbox-box { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue b/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue new file mode 100644 index 00000000..d5fdfc61 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue @@ -0,0 +1,547 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/date/date-picker.vue b/code/fms/fms-vue/src/components/ui/date/date-picker.vue new file mode 100644 index 00000000..01d9722a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/date-picker.vue @@ -0,0 +1,186 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/date/date-utils.js b/code/fms/fms-vue/src/components/ui/date/date-utils.js new file mode 100644 index 00000000..32f3257e --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/date-utils.js @@ -0,0 +1,287 @@ +import dayjs from 'dayjs' +import advancedFormat from 'dayjs/plugin/advancedFormat.js' +import customParseFormat from 'dayjs/plugin/customParseFormat.js' + +dayjs.extend(customParseFormat) +dayjs.extend(advancedFormat) + +/** 默认日期时间格式(含时分秒) */ +export const DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm:ss' +/** 纯日期格式(不含时间) */ +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD' +/** 星期表头(日 → 六),与 getMonthMatrix 的列序一致 */ +export const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'] + +const FORMAT_TOKENS = [ + 'YYYY', + 'MMMM', + 'dddd', + 'MMM', + 'ddd', + 'SSS', + 'YY', + 'MM', + 'DD', + 'Do', + 'dd', + 'HH', + 'hh', + 'mm', + 'ss', + 'ZZ', + 'M', + 'D', + 'd', + 'H', + 'h', + 'm', + 's', + 'Z', + 'A', + 'a', +] + +/** 规范化 format / valueFormat prop: + * - 字符串 → [字符串] + * - 数组 → 原样(以第一个为准,参考 dayjs#format 与 @rc-component/picker 约定) + * - 其余 → 默认格式 + */ +export function normalizeFormats(format, fallback) { + if (Array.isArray(format)) { + const formats = format.filter((item) => typeof item === 'string' && item.length > 0) + return formats.length ? formats : [fallback] + } + return format ? [format] : [fallback] +} + +/** format prop 是否包含有效格式 */ +export function hasFormat(format) { + return Array.isArray(format) + ? format.some((item) => typeof item === 'string' && item.length > 0) + : typeof format === 'string' && format.length > 0 +} + +/** 返回格式数组中的首个有效格式 */ +export function getPrimaryFormat(format, fallback = DEFAULT_DATE_FORMAT) { + return normalizeFormats(format, fallback)[0] +} + +/** 按 Dayjs 规则拆分格式,方括号内的内容始终按普通文本处理。 */ +export function tokenizeDateFormat(format) { + const source = String(format || '') + const tokens = [] + let index = 0 + + while (index < source.length) { + if (source[index] === '[') { + const end = source.indexOf(']', index + 1) + index = end === -1 ? source.length : end + 1 + continue + } + + const token = FORMAT_TOKENS.find((candidate) => source.startsWith(candidate, index)) + if (token) { + tokens.push(token) + index += token.length + } else index += 1 + } + + return tokens +} + +/** 从 format 推导选择面板和时间精度;valueFormat 不参与控件推导。 */ +export function analyzeDateFormat(format) { + const primaryFormat = getPrimaryFormat(format) + const tokens = tokenizeDateFormat(primaryFormat) + const hasYear = tokens.some((token) => token === 'YY' || token === 'YYYY') + const hasMonth = tokens.some((token) => ['M', 'MM', 'MMM', 'MMMM'].includes(token)) + const hasDay = tokens.some((token) => token === 'D' || token === 'DD' || token === 'Do') + const hourToken = tokens.find((token) => ['H', 'HH', 'h', 'hh'].includes(token)) || '' + const hasHour = Boolean(hourToken) + const hasMinute = tokens.some((token) => token === 'm' || token === 'mm') + const hasSecond = tokens.some((token) => token === 's' || token === 'ss') + const hasMillisecond = tokens.includes('SSS') + const meridiemToken = tokens.find((token) => token === 'A' || token === 'a') || '' + const timeFields = [] + if (hasHour) timeFields.push('hour') + if (hasMinute) timeFields.push('minute') + if (hasSecond) timeFields.push('second') + + // 星期、时区和纯字面量只能展示,无法唯一反解析为一个日期。 + // 这类 format 回退为默认日期,避免渲染出没有任何可选项的空面板。 + if (!hasYear && !hasMonth && !hasDay && timeFields.length === 0) { + return analyzeDateFormat(DEFAULT_DATE_FORMAT) + } + + return { + primaryFormat, + tokens, + hasYear, + hasMonth, + hasDay, + hasDateParts: hasYear || hasMonth || hasDay, + hasHour, + hasMinute, + hasSecond, + hasMillisecond, + hasTime: timeFields.length > 0, + timeFields, + hour12: hourToken === 'h' || hourToken === 'hh', + meridiem: Boolean(meridiemToken), + lowerMeridiem: meridiemToken === 'a', + } +} + +/** 将 Date / dayjs / 时间戳统一为 dayjs 对象;非法输入返回 null */ +export function toDayjs(value) { + if (value == null) return null + const d = dayjs(value) + return d.isValid() ? d : null +} + +/** 将外部绑定值统一为 Date;字符串优先按指定格式严格解析 */ +export function normalizeDateValue(value, format = DEFAULT_FORMAT) { + if (value == null || value === '') return null + if (typeof value === 'string') return parseDate(value, format) + const d = toDayjs(value) + return d ? d.toDate() : null +} + +/** 将不可选择的低精度字段归一化,保证 valueFormat 补出的值是确定的。 */ +export function normalizeDateForFormat(value, format) { + const config = + typeof format === 'string' || Array.isArray(format) ? analyzeDateFormat(format) : format + const source = toDayjs(value) + if (!source) return null + let result = source + + if (config.hasYear && !config.hasMonth) result = result.month(0) + if ((config.hasYear || config.hasMonth) && !config.hasDay) result = result.date(1) + if (!config.hasHour) result = result.hour(0) + if (!config.hasMinute) result = result.minute(0) + if (!config.hasSecond) result = result.second(0) + if (!config.hasMillisecond) result = result.millisecond(0) + return result.toDate() +} + +/** 按 format(支持数组)把日期格式化为字符串,首个格式为准;非法返回 '' */ +export function formatDate(value, format = DEFAULT_FORMAT) { + const d = toDayjs(value) + if (!d) return '' + const [primary] = normalizeFormats(format, DEFAULT_FORMAT) + return d.format(primary) +} + +/** 按 format(支持数组多格式匹配)解析字符串为 Date;全部失败回退 ISO,仍失败返回 null */ +export function parseDate(value, format = DEFAULT_FORMAT) { + if (value == null || value === '') return null + if (typeof value !== 'string') { + const d = toDayjs(value) + return d ? d.toDate() : null + } + const fmts = normalizeFormats(format, DEFAULT_FORMAT) + for (const f of fmts) { + const d = dayjs(value, f, true) // 严格解析,避免 'abc' 被宽松解析 + if (d.isValid()) return d.toDate() + } + const iso = dayjs(value) + return iso.isValid() ? iso.toDate() : null +} + +/** 取某日期的「日」部分(时/分/秒归零),用于纯日期选择 */ +export function startOfDay(value) { + const d = toDayjs(value) + return d ? d.startOf('day').toDate() : null +} + +/** 取某日期的「日」末尾(23:59:59.999),用于范围结束端默认值 */ +export function endOfDay(value) { + const d = toDayjs(value) + return d ? d.endOf('day').toDate() : null +} + +/** 判断两个日期是否同一天(忽略时间) */ +export function isSameDay(a, b) { + const da = toDayjs(a) + const db = toDayjs(b) + return da && db ? da.isSame(db, 'day') : false +} + +/** 判断两个日期是否同月 */ +export function isSameMonth(a, b) { + const da = toDayjs(a) + const db = toDayjs(b) + return da && db ? da.isSame(db, 'month') : false +} + +/** 返回日期是否为今天 */ +export function isToday(value) { + return isSameDay(value, new Date()) +} + +/** 在 date 基础上加减 n 个月,返回 Date */ +export function addMonth(value, n) { + const d = toDayjs(value) ?? dayjs() + return d.add(n, 'month').toDate() +} + +/** 比较两日期:ab → 1,相等 → 0(按天比较) */ +export function compareDay(a, b) { + const da = toDayjs(a) + const db = toDayjs(b) + if (!da || !db) return 0 + if (da.isBefore(db, 'day')) return -1 + if (da.isAfter(db, 'day')) return 1 + return 0 +} + +/** 比较两个完整时间点,非法值视为相等 */ +export function compareDate(a, b) { + const da = toDayjs(a) + const db = toDayjs(b) + if (!da || !db) return 0 + if (da.isBefore(db)) return -1 + if (da.isAfter(db)) return 1 + return 0 +} + +/** date 是否落在 [start, end] 闭区间内(含两端,按天比较) */ +export function isInRange(date, start, end) { + if (!date || !start || !end) return false + const d = toDayjs(date) + const s = startOfDay(start) + const e = startOfDay(end) + if (!d || !s || !e) return false + const lo = dayjs(s).isBefore(e) ? s : e + const hi = lo === s ? e : s + return !d.isBefore(lo, 'day') && !d.isAfter(hi, 'day') +} + +/** 合并「日期部分」与「时间部分」:以 date 的年月日 + time 的时分秒组成新 Date。 + * time 省略时使用 00:00:00;date 省略时使用今天。 */ +export function combineDateTime(date, time) { + const base = toDayjs(date) ?? dayjs() + if (time == null) return base.startOf('day').toDate() + const t = toDayjs(time) + if (!t) return base.startOf('day').toDate() + return base.hour(t.hour()).minute(t.minute()).second(t.second()).millisecond(0).toDate() +} + +/** 取某年某月的日历矩阵(6×7 = 42 格),含上/下月补位。 + * 返回 dayjs 数组,索引 0 为当月 1 号所在周的周日。 */ +export function getMonthMatrix(viewYear, viewMonth) { + const first = dayjs().year(viewYear).month(viewMonth).date(1) + const leading = first.day() // 0=周日 + const start = first.subtract(leading, 'day') + const cells = [] + for (let i = 0; i < 42; i++) cells.push(start.add(i, 'day')) + return cells +} + +/** 将矩阵按周拆成 6 行(每行 7 个 dayjs),便于 v-for 渲染 */ +export function getMonthRows(viewYear, viewMonth) { + const cells = getMonthMatrix(viewYear, viewMonth) + const rows = [] + for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7)) + return rows +} diff --git a/code/fms/fms-vue/src/components/ui/date/index.scss b/code/fms/fms-vue/src/components/ui/date/index.scss new file mode 100644 index 00000000..05f0f7e5 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/index.scss @@ -0,0 +1,536 @@ +/* ============================================================ + * DatePicker / RangePicker 样式 + * 沿用 fms 主题 CSS 变量(见 src/theme/tokens.css) + * ============================================================ */ + +.fms-date-picker, +.fms-range-picker { + display: block; + width: 100%; + /* 窄容器下保持日历可读(7 列日期格子约 31px), + 浮层跟随触发器宽度,视觉统一不受影响 */ + min-width: 240px; + + /* 让触发器撑满组件宽度(覆盖 popover 默认 inline-flex) */ + .popover-trigger { + display: block; + width: 100%; + } +} + +/* ---------- 触发器(类 Input 外观) ---------- */ +.fms-date-trigger, +.fms-range-trigger { + display: flex; + align-items: center; + gap: 6px; + height: var(--fms-control-height); + padding: 0 var(--fms-control-padding-x); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + background: var(--fms-card); + color: var(--fms-text); + font-size: var(--fms-font-size); + font-family: inherit; + cursor: pointer; + transition: + border-color 150ms ease, + box-shadow 150ms ease; +} + +.fms-date-trigger:hover, +.fms-range-trigger:hover, +.fms-date-picker.is-open .fms-date-trigger, +.fms-range-picker.is-open .fms-range-trigger { + border-color: var(--fms-primary); +} + +.fms-date-picker.is-open .fms-date-trigger, +.fms-range-picker.is-open .fms-range-trigger { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 18%, transparent); +} + +.fms-date-trigger:focus-within, +.fms-range-trigger:focus-within { + border-color: var(--fms-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 18%, transparent); +} + +.fms-date-prefix { + display: inline-flex; + color: var(--fms-text-secondary); + flex-shrink: 0; +} + +.fms-date-input, +.fms-range-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: var(--fms-text); + font-size: var(--fms-font-size); + font-family: inherit; + cursor: pointer; +} + +.fms-range-input { + text-align: center; +} + +.fms-range-sep { + color: var(--fms-text-secondary); + flex-shrink: 0; +} + +.fms-date-suffix { + display: inline-flex; + align-items: center; + color: var(--fms-text-secondary); + flex-shrink: 0; +} + +.fms-date-clear { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; +} + +.fms-date-clear:hover { + color: var(--fms-text); + background: var(--fms-secondary-hover); +} + +.fms-date-clear:focus-visible, +.fms-cal-nav:focus-visible, +.fms-cal-title-button:focus-visible, +.fms-cal-cell:focus-visible, +.fms-calendar-selector-item:focus-visible, +.fms-time-list:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 1px; +} + +.fms-date-arrow { + transition: transform 150ms ease; +} + +.fms-date-picker.is-open .fms-date-arrow, +.fms-range-picker.is-open .fms-date-arrow { + transform: rotate(180deg); +} + +/* 禁用态 */ +.fms-date-picker.is-disabled .fms-date-trigger, +.fms-range-picker.is-disabled .fms-range-trigger { + background: var(--fms-disabled-bg); + color: var(--fms-disabled-text); + border-color: var(--fms-disabled-border); + cursor: not-allowed; +} + +.fms-date-picker.is-disabled .fms-date-input, +.fms-range-picker.is-disabled .fms-range-input { + cursor: not-allowed; +} + +/* ---------- 日历面板 ---------- */ +.fms-calendar-panel { + --fms-cal-h: 282px; /* 274px 日历内容 + 8px 底部留白,时间区使用同一总高度 */ + width: 100%; + user-select: none; + -webkit-user-select: none; +} + +.fms-calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.fms-cal-nav-group { + display: inline-flex; + flex: 0 0 auto; +} + +/* 日历网格填满面板(跟随触发器宽度),1fr 让 7 列日期自适应均分 */ +.fms-calendar-grid { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + width: 100%; +} + +.fms-cal-nav { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; +} + +.fms-cal-nav:hover { + background: var(--fms-secondary-hover); + color: var(--fms-text); +} + +.fms-cal-title { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 104px; +} + +.fms-cal-title-button { + height: 28px; + padding: 0 4px; + border: none; + border-radius: 4px; + background: transparent; + font-size: 14px; + font-weight: 600; + font-family: inherit; + color: var(--fms-text); + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; +} + +.fms-cal-title-button:hover, +.fms-cal-title-button.is-active { + background: var(--fms-secondary-hover); + color: var(--fms-primary); +} + +.fms-calendar-selector { + box-sizing: border-box; + flex: 1 1 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(4, 1fr); + gap: 8px; + width: 100%; + min-height: 0; + padding: 12px 4px 4px; +} + +.fms-calendar-selector-item { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 4px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--fms-text); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.fms-calendar-selector-item:hover { + background: var(--fms-secondary-hover); +} + +.fms-calendar-selector-item.is-current:not(.is-selected) { + box-shadow: inset 0 0 0 1px var(--fms-primary); + color: var(--fms-primary); +} + +.fms-calendar-selector-item.is-selected { + background: var(--fms-primary); + color: var(--fms-primary-text); + font-weight: 600; +} + +.fms-cal-weekdays { + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 4px; +} + +.fms-cal-weekday { + text-align: center; + padding: 4px 0; + font-size: 12px; + color: var(--fms-text-secondary); +} + +.fms-cal-rows { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.fms-cal-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.fms-cal-cell { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: 34px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + font-size: 13px; + font-family: inherit; + color: var(--fms-text); + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.fms-cal-cell:hover { + background: var(--fms-secondary-hover); +} + +.fms-cal-cell.is-other-month { + color: var(--fms-text-secondary); + opacity: 0.45; +} + +.fms-cal-cell.is-disabled { + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +.fms-cal-cell:disabled { + opacity: 1; +} + +.fms-cal-cell.is-disabled:hover { + background: transparent; +} + +/* 今日标记 */ +.fms-cal-cell.is-today:not(.is-selected):not(.is-range-start):not(.is-range-end) + .fms-cal-cell-inner { + font-weight: 600; + color: var(--fms-primary); +} + +.fms-cal-cell.is-today:not(.is-selected):not(.is-range-start):not(.is-range-end)::after { + content: ''; + position: absolute; + bottom: 4px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--fms-primary); +} + +/* 单选选中 */ +.fms-cal-cell.is-selected { + background: var(--fms-primary); + color: var(--fms-primary-text); + font-weight: 600; +} + +/* 范围区间底色 */ +.fms-cal-cell.is-in-range, +.fms-cal-cell.is-in-hover-range { + background: color-mix(in srgb, var(--fms-primary) 12%, transparent); + border-radius: 0; +} + +/* 范围端点(声明在 in-range 之后,优先级更高) */ +.fms-cal-cell.is-range-start, +.fms-cal-cell.is-range-end { + background: var(--fms-primary); + color: var(--fms-primary-text); + font-weight: 600; +} + +.fms-cal-cell.is-range-start { + border-radius: 6px 0 0 6px; +} + +.fms-cal-cell.is-range-end { + border-radius: 0 6px 6px 0; +} + +.fms-cal-cell.is-range-single { + border-radius: 6px; +} + +/* ---------- 时间选择(右侧面板,antd 风格:左日期 / 右时间) ---------- */ +.fms-calendar-panel.has-time { + display: flex; + flex-direction: column; + width: 100%; +} + +/* 主体行:左日期 + 右时间并排;align-items:stretch 让时间区高度撑满 */ +.fms-calendar-body { + display: flex; + align-items: stretch; +} + +.fms-calendar-main { + flex: 1 1 auto; + height: var(--fms-cal-h); + display: flex; + flex-direction: column; + padding-right: 8px; +} + +.fms-calendar-time { + display: flex; + flex-direction: column; + justify-content: center; + gap: 16px; + padding-left: 12px; + border-left: 1px solid var(--fms-border); +} + +.fms-calendar-panel.is-time-only .fms-calendar-time { + height: var(--fms-cal-h); + padding-left: 0; + border-left: none; +} + +/* 范围模式:开始 / 结束 两组左右排列 */ +.fms-calendar-time .fms-time-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.fms-calendar-panel.is-range .fms-calendar-time { + flex-direction: row; + align-items: stretch; + gap: 0; +} + +.fms-calendar-panel.is-range .fms-time-group { + height: var(--fms-cal-h); + gap: 4px; +} + +.fms-calendar-panel.is-range .fms-time-group .fms-time-select { + flex: 1 1 auto; + min-height: 0; +} + +.fms-calendar-panel.is-range .fms-time-group + .fms-time-group { + border-left: 1px solid var(--fms-border); +} + +.fms-time-label { + font-size: 12px; + color: var(--fms-text-secondary); + flex-shrink: 0; +} + +.fms-time-select { + display: flex; + align-items: stretch; +} + +/* 单选模式:时间区与日历主体同高,列表填充后溢出滚动 */ +.fms-calendar-time > .fms-time-select { + height: var(--fms-cal-h); +} + +.fms-time-col { + display: flex; + flex-direction: column; + align-items: center; + width: 56px; +} + +.fms-time-col:not(:first-child) { + border-left: 1px solid var(--fms-border); +} + +.fms-time-list { + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; +} + +.fms-time-list:focus-visible { + position: relative; + z-index: 1; +} + +.fms-time-list ul { + list-style: none; + margin: 0; + padding: 0; +} + +.fms-time-item { + width: 100%; + height: 28px; + line-height: 28px; + text-align: center; + font-size: 14px; + color: var(--fms-text); + border-radius: 4px; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.fms-time-item:hover { + background: var(--fms-secondary-hover); +} + +.fms-time-item.is-active { + background: var(--fms-primary); + color: var(--fms-primary-text); + font-weight: 500; +} + +@media (prefers-reduced-motion: reduce) { + .fms-date-trigger, + .fms-range-trigger, + .fms-date-clear, + .fms-date-arrow, + .fms-cal-nav, + .fms-cal-title-button, + .fms-cal-cell, + .fms-calendar-selector-item, + .fms-time-item { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/date/range-picker.vue b/code/fms/fms-vue/src/components/ui/date/range-picker.vue new file mode 100644 index 00000000..77b256c2 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/range-picker.vue @@ -0,0 +1,259 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/date/time-select.vue b/code/fms/fms-vue/src/components/ui/date/time-select.vue new file mode 100644 index 00000000..37c2f0fc --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/date/time-select.vue @@ -0,0 +1,150 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/button-group.vue b/code/fms/fms-vue/src/components/ui/demo/button-group.vue new file mode 100644 index 00000000..b41db379 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/button-group.vue @@ -0,0 +1,51 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/button.vue b/code/fms/fms-vue/src/components/ui/demo/button.vue new file mode 100644 index 00000000..3ea0c3f2 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/button.vue @@ -0,0 +1,53 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/checkbox.vue b/code/fms/fms-vue/src/components/ui/demo/checkbox.vue new file mode 100644 index 00000000..34d93808 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/checkbox.vue @@ -0,0 +1,55 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/date.vue b/code/fms/fms-vue/src/components/ui/demo/date.vue new file mode 100644 index 00000000..294e6e2e --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/date.vue @@ -0,0 +1,71 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/drawer.vue b/code/fms/fms-vue/src/components/ui/demo/drawer.vue new file mode 100644 index 00000000..f85861e1 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/drawer.vue @@ -0,0 +1,68 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/dropdown.vue b/code/fms/fms-vue/src/components/ui/demo/dropdown.vue new file mode 100644 index 00000000..5de06311 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/dropdown.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/form.vue b/code/fms/fms-vue/src/components/ui/demo/form.vue new file mode 100644 index 00000000..14a21df7 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/form.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/grid.vue b/code/fms/fms-vue/src/components/ui/demo/grid.vue new file mode 100644 index 00000000..334a1705 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/grid.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/input.vue b/code/fms/fms-vue/src/components/ui/demo/input.vue new file mode 100644 index 00000000..796186ae --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/input.vue @@ -0,0 +1,72 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/message.vue b/code/fms/fms-vue/src/components/ui/demo/message.vue new file mode 100644 index 00000000..6e2b7088 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/message.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/modal.vue b/code/fms/fms-vue/src/components/ui/demo/modal.vue new file mode 100644 index 00000000..8b8c8108 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/modal.vue @@ -0,0 +1,104 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/notification.vue b/code/fms/fms-vue/src/components/ui/demo/notification.vue new file mode 100644 index 00000000..442b3f73 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/notification.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/pagination.vue b/code/fms/fms-vue/src/components/ui/demo/pagination.vue new file mode 100644 index 00000000..ef4413dd --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/pagination.vue @@ -0,0 +1,78 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/popover.vue b/code/fms/fms-vue/src/components/ui/demo/popover.vue new file mode 100644 index 00000000..751782cc --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/popover.vue @@ -0,0 +1,95 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/radio.vue b/code/fms/fms-vue/src/components/ui/demo/radio.vue new file mode 100644 index 00000000..a28f1f87 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/radio.vue @@ -0,0 +1,41 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/select.vue b/code/fms/fms-vue/src/components/ui/demo/select.vue new file mode 100644 index 00000000..1265b5d1 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/select.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/spin.vue b/code/fms/fms-vue/src/components/ui/demo/spin.vue new file mode 100644 index 00000000..0b937327 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/spin.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/splitter.vue b/code/fms/fms-vue/src/components/ui/demo/splitter.vue new file mode 100644 index 00000000..628a0489 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/splitter.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/switch.vue b/code/fms/fms-vue/src/components/ui/demo/switch.vue new file mode 100644 index 00000000..e7360f92 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/switch.vue @@ -0,0 +1,30 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/tabs.vue b/code/fms/fms-vue/src/components/ui/demo/tabs.vue new file mode 100644 index 00000000..236fe508 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/tabs.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/tag.vue b/code/fms/fms-vue/src/components/ui/demo/tag.vue new file mode 100644 index 00000000..990de966 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/tag.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/tooltip.vue b/code/fms/fms-vue/src/components/ui/demo/tooltip.vue new file mode 100644 index 00000000..dc014095 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/tooltip.vue @@ -0,0 +1,68 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/demo/tree.vue b/code/fms/fms-vue/src/components/ui/demo/tree.vue new file mode 100644 index 00000000..d5692065 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/demo/tree.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/drawer/drawer.vue b/code/fms/fms-vue/src/components/ui/drawer/drawer.vue new file mode 100644 index 00000000..4e6c15d1 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/drawer/drawer.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/drawer/index.scss b/code/fms/fms-vue/src/components/ui/drawer/index.scss new file mode 100644 index 00000000..06a3d519 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/drawer/index.scss @@ -0,0 +1,270 @@ +/* ---------- 遮罩 ---------- */ +.fms-drawer-mask { + position: fixed; + inset: 0; + z-index: var(--fms-z-modal); + background: var(--fms-mask-bg); +} + +.fms-drawer-mask-enter-active, +.fms-drawer-mask-leave-active { + transition: opacity 160ms ease; +} + +.fms-drawer-mask-enter-from, +.fms-drawer-mask-leave-to { + opacity: 0; +} + +/* ---------- 定位容器:按方向吸附到视口边缘 ---------- */ +.fms-drawer-wrap { + position: fixed; + inset: 0; + z-index: var(--fms-z-modal); + pointer-events: none; +} + +/* 面板本体可交互,容器不拦截遮罩之外区域的点击 */ +.fms-drawer { + display: flex; + flex-direction: column; + box-sizing: border-box; + max-width: 100vw; + max-height: 100vh; + background: var(--fms-card); + box-shadow: var(--fms-popover-shadow); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + outline: none; + pointer-events: auto; +} + +.fms-drawer:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 左右方向:撑满垂直高度;上下方向:撑满水平宽度 */ +.fms-drawer-left .fms-drawer, +.fms-drawer-right .fms-drawer { + position: absolute; + top: 0; + bottom: 0; +} + +.fms-drawer-top .fms-drawer, +.fms-drawer-bottom .fms-drawer { + position: absolute; + left: 0; + right: 0; +} + +.fms-drawer-left .fms-drawer { + left: 0; +} + +.fms-drawer-right .fms-drawer { + right: 0; +} + +.fms-drawer-top .fms-drawer { + top: 0; +} + +.fms-drawer-bottom .fms-drawer { + bottom: 0; +} + +/* ---------- 头部:extra + 标题 + 关闭按钮 ---------- */ +.fms-drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: var(--fms-drawer-header-padding); + border-bottom: 1px solid var(--fms-border); +} + +.fms-drawer-header-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.fms-drawer-extra { + flex-shrink: 0; +} + +.fms-drawer-title { + margin: 0; + font-size: var(--fms-font-size); + font-weight: 600; + color: var(--fms-text); + word-break: break-word; +} + +.fms-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.fms-drawer-close:hover { + background: var(--fms-secondary-hover); + color: var(--fms-text); +} + +.fms-drawer-close:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* ---------- 内容区:撑满剩余空间并可滚动 ---------- */ +.fms-drawer-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: var(--fms-drawer-body-padding); + color: var(--fms-text); + word-break: break-word; +} + +/* ---------- 底部 ---------- */ +.fms-drawer-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; + padding: var(--fms-drawer-footer-padding); + border-top: 1px solid var(--fms-border); +} + +/* ---------- 缩放手柄:沿边缘可拖拽调整尺寸 ---------- */ +/* 命中区保持 6px 透明;拖拽线用 --fms-splitter-bar-size-hover 控制宽度,与 splitter 一致 */ +.fms-drawer-resizer { + position: absolute; + touch-action: none; +} + +/* 拖拽线:hover / 拖拽时显示居中的灰色细线 */ +.fms-drawer-resizer::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--fms-text-secondary); + opacity: 0; + transition: opacity 150ms ease; +} + +.fms-drawer-resizer:hover::before, +.fms-drawer-resizer.is-resizing::before { + opacity: 1; +} + +/* 左右抽屉:拖纵向边缘(e 右缘 / w 左缘)调整宽度 */ +.fms-drawer-resizer-e, +.fms-drawer-resizer-w { + top: 0; + bottom: 0; + width: 6px; + cursor: ew-resize; +} + +.fms-drawer-resizer-e::before, +.fms-drawer-resizer-w::before { + width: var(--fms-splitter-bar-size-hover); + height: 100%; +} + +.fms-drawer-resizer-e { + right: 0; +} + +.fms-drawer-resizer-w { + left: 0; +} + +/* 上下抽屉:拖横向边缘(s 底缘 / n 顶缘)调整高度 */ +.fms-drawer-resizer-s, +.fms-drawer-resizer-n { + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; +} + +.fms-drawer-resizer-s::before, +.fms-drawer-resizer-n::before { + width: 100%; + height: var(--fms-splitter-bar-size-hover); +} + +.fms-drawer-resizer-s { + bottom: 0; +} + +.fms-drawer-resizer-n { + top: 0; +} + +/* ---------- 面板过渡:按方向滑入滑出 ---------- */ +.fms-drawer-enter-active, +.fms-drawer-leave-active { + /* fade 与 slide 时长保持同步(300ms),避免淡出提前完成导致滑出后半段不可见 */ + transition: + opacity 300ms ease, + transform 300ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.fms-drawer-enter-from, +.fms-drawer-leave-to { + opacity: 0; +} + +/* 位移动画作用于 wrap 自身(与 transition 同一元素,transition 不继承): + 全屏容器按方向移出视口,面板随之滑入/滑出 */ +.fms-drawer-left.fms-drawer-enter-from, +.fms-drawer-left.fms-drawer-leave-to { + transform: translateX(-100%); +} + +.fms-drawer-right.fms-drawer-enter-from, +.fms-drawer-right.fms-drawer-leave-to { + transform: translateX(100%); +} + +.fms-drawer-top.fms-drawer-enter-from, +.fms-drawer-top.fms-drawer-leave-to { + transform: translateY(-100%); +} + +.fms-drawer-bottom.fms-drawer-enter-from, +.fms-drawer-bottom.fms-drawer-leave-to { + transform: translateY(100%); +} + +@media (prefers-reduced-motion: reduce) { + .fms-drawer-enter-active, + .fms-drawer-leave-active, + .fms-drawer-mask-enter-active, + .fms-drawer-mask-leave-active { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue b/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue new file mode 100644 index 00000000..6ffc1ff4 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/dropdown/index.scss b/code/fms/fms-vue/src/components/ui/dropdown/index.scss new file mode 100644 index 00000000..8d323dce --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/dropdown/index.scss @@ -0,0 +1,136 @@ +/* Dropdown 下拉菜单 —— 卡片浮层 + 菜单项(样式参考 shadcn DropdownMenu) */ + +/* 菜单卡片:以负 margin 抵消 popover-content 的 8px padding, + 让卡片自带背景 / 边框 / 圆角贴齐浮层边缘,视觉与 shadcn 菜单一致 */ +.dropdown-menu { + box-sizing: border-box; + margin: calc(-1 * var(--fms-popover-padding)); + min-width: var(--fms-dropdown-min-width); + max-height: var(--fms-dropdown-max-height); + overflow-y: auto; + padding: 4px; + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + outline: none; +} + +/* 菜单项:图标 + 文字 + 快捷键单行布局,hover / 键盘高亮 */ +.dropdown-item { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + padding: var(--fms-dropdown-item-padding-y) var(--fms-dropdown-item-padding-x); + border: none; + border-radius: var(--fms-dropdown-item-radius); + background: transparent; + color: inherit; + font: inherit; + font-weight: 500; + line-height: var(--fms-line-height); + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: background-color 150ms ease; + + :deep(svg) { + flex-shrink: 0; + width: 1em; + height: 1em; + } +} + +/* 子菜单项包裹在嵌套 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); +} + +.dropdown-item:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: -2px; +} + +/* 图标位:1em 宽,仅在菜单项配置了图标时渲染 */ +.dropdown-item-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1em; + color: var(--fms-text-secondary); +} + +.dropdown-item-label { + overflow: hidden; + text-overflow: ellipsis; +} + +/* 快捷键:右侧浅灰小字 */ +.dropdown-item-shortcut { + flex-shrink: 0; + margin-left: auto; + padding-left: 16px; + font-size: 12px; + color: var(--fms-text-secondary); +} + +/* 子菜单展开箭头:靠右指示 */ +.dropdown-item-caret { + flex-shrink: 0; + margin-left: auto; + padding-left: 8px; + color: var(--fms-text-secondary); +} + +/* 危险项:文字红色,hover 淡红底(参考 shadcn destructive 变体) */ +.dropdown-item-danger { + color: var(--fms-danger); +} + +.dropdown-item-danger:hover:not(.dropdown-item-disabled), +.dropdown-item-danger.dropdown-item-active { + background: color-mix(in srgb, var(--fms-danger) 10%, transparent); +} + +/* 禁用项:灰字不可点 */ +.dropdown-item-disabled { + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +.dropdown-item-disabled:hover { + background: transparent; +} + +/* 分组标签 */ +.dropdown-label { + padding: var(--fms-dropdown-item-padding-y) var(--fms-dropdown-item-padding-x); + font-size: 12px; + color: var(--fms-text-secondary); + user-select: none; +} + +/* 分隔线 */ +.dropdown-divider { + height: 1px; + margin: var(--fms-dropdown-divider-margin); + background: var(--fms-border); +} + +@media (prefers-reduced-motion: reduce) { + .dropdown-item { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/dropdown/menu.vue b/code/fms/fms-vue/src/components/ui/dropdown/menu.vue new file mode 100644 index 00000000..1a5a696c --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/dropdown/menu.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/form/form-item.vue b/code/fms/fms-vue/src/components/ui/form/form-item.vue new file mode 100644 index 00000000..af2744b6 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/form/form-item.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/form/form.vue b/code/fms/fms-vue/src/components/ui/form/form.vue new file mode 100644 index 00000000..d3fd398d --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/form/form.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/form/index.scss b/code/fms/fms-vue/src/components/ui/form/index.scss new file mode 100644 index 00000000..c77231e2 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/form/index.scss @@ -0,0 +1,99 @@ +/* ============================================================ + * Form / FormItem 样式 + * 复用 fms 主题 token(见 src/theme/tokens.css) + * ============================================================ */ + +/* ---------- 表单项 ---------- */ +.fms-form-item { + display: flex; + align-items: flex-start; + margin-bottom: var(--fms-form-item-margin-bottom); + + &:last-child { + margin-bottom: 0; + } +} + +/* 纵向布局:label 在上、控件在下。 + 需显式恢复交叉轴拉伸,否则继承 .fms-form-item 的 align-items: flex-start, + 控件区宽度会收缩到内容宽度,导致内部控件 width: 100% 不生效 */ +.fms-form-item-vertical { + flex-direction: column; + align-items: stretch; +} + +/* ---------- label ---------- */ +.fms-form-item-label { + flex-shrink: 0; + padding-right: 12px; + font-size: var(--fms-font-size); + line-height: var(--fms-control-height); + color: var(--fms-text); + user-select: none; +} + +/* 横向布局:flex 保证必填星、文本恒在同一行 */ +.fms-form-horizontal .fms-form-item-label { + display: flex; + align-items: baseline; + width: var(--fms-form-label-width); + white-space: nowrap; +} + +/* 靠右对齐:flex 容器下用 justify-content 实现右缘与控件严格对齐 + (行业惯例,2/3/4 字 label 字距自然、右缘整齐) */ +.fms-form-horizontal .fms-form-label-right { + justify-content: flex-end; +} + +.fms-form-horizontal .fms-form-label-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 纵向布局:label 为块级,默认靠左(无需覆盖 labelAlign 的 right) */ +.fms-form-label-vertical { + display: block; + padding-right: 0; + padding-bottom: 4px; + line-height: var(--fms-line-height); + text-align: left; +} + +.fms-form-required-mark { + flex-shrink: 0; + margin-right: 4px; + color: var(--fms-danger); +} + +/* ---------- 控件区 ---------- */ +.fms-form-item-control { + flex: 1; + min-width: 0; +} + +/* ---------- 提示文字 ---------- */ +.fms-form-item-help, +.fms-form-item-extra { + margin-top: 4px; + font-size: var(--fms-form-help-font-size); + line-height: 1.5; + color: var(--fms-text-secondary); +} + +.fms-form-item-help-error { + color: var(--fms-danger); +} + +/* 错误文字进场仅做透明度过渡,低成本且支持减弱动效 */ +.fms-form-item-help-error { + transition: opacity 150ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .fms-form-item-help-error { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/form/validate.js b/code/fms/fms-vue/src/components/ui/form/validate.js new file mode 100644 index 00000000..e13f9fa9 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/form/validate.js @@ -0,0 +1,119 @@ +/** + * 轻量表单校验纯函数 + * 按规则对象逐条执行,返回 Promise(空数组表示通过)。 + * 规则支持:required / type / pattern / len / min / max / validator。 + * 每个规则对象可同时携带多个约束,错误消息全部收集后返回。 + */ + +/** 表单语义下的空值:undefined / null / 空字符串 */ +export function isEmptyValue(value) { + return value === undefined || value === null || value === '' +} + +/** 类型校验器:number / integer 允许数字字符串(如 Input number 模式提交前的字符串中间态) */ +const TYPE_VALIDATORS = { + string: (value) => typeof value === 'string', + number: (value) => { + if (typeof value === 'number') return !Number.isNaN(value) + return typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value)) + }, + integer: (value) => { + if (typeof value === 'number') return Number.isInteger(value) + return typeof value === 'string' && value.trim() !== '' && Number.isInteger(Number(value)) + }, + email: (value) => typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), + url: (value) => typeof value === 'string' && /^https?:\/\/[^\s]+$/i.test(value), +} + +/** 值的"长度":字符串取字符数,数组取项数,数字取数值本身 */ +function measure(value) { + if (typeof value === 'string' || Array.isArray(value)) return value.length + if (typeof value === 'number') return value + return undefined +} + +/** 默认错误消息模板;规则里可传 message 覆盖 */ +const DEFAULT_MESSAGES = { + required: (label) => (label ? `${label}不能为空` : '不能为空'), + type: (label) => (label ? `${label}格式不正确` : '格式不正确'), + pattern: (label) => (label ? `${label}格式不正确` : '格式不正确'), + len: (label, rule) => (label ? `${label}长度必须为 ${rule.len}` : `长度必须为 ${rule.len}`), + min: (label, rule) => (label ? `${label}长度不能少于 ${rule.min}` : `长度不能少于 ${rule.min}`), + max: (label, rule) => (label ? `${label}长度不能超过 ${rule.max}` : `长度不能超过 ${rule.max}`), +} + +/** 执行规则中的静态约束(required / type / pattern / len / min / max) */ +function validateStatic(rule, { label, value }) { + // 必填失败:空值直接返回,其余约束不再执行 + if (rule.required && isEmptyValue(value)) { + return [rule.message || DEFAULT_MESSAGES.required(label)] + } + + // 非必填约束只在有值时执行,避免"必填"与"格式"重复报错 + if (isEmptyValue(value)) return [] + + const errors = [] + + if (rule.type && !TYPE_VALIDATORS[rule.type]?.(value)) { + errors.push(rule.message || DEFAULT_MESSAGES.type(label)) + } + + if (rule.pattern && !rule.pattern.test(value)) { + errors.push(rule.message || DEFAULT_MESSAGES.pattern(label)) + } + + const length = measure(value) + if (length !== undefined) { + if (rule.len !== undefined && length !== rule.len) { + errors.push(rule.message || DEFAULT_MESSAGES.len(label, rule)) + } + if (rule.min !== undefined && length < rule.min) { + errors.push(rule.message || DEFAULT_MESSAGES.min(label, rule)) + } + if (rule.max !== undefined && length > rule.max) { + errors.push(rule.message || DEFAULT_MESSAGES.max(label, rule)) + } + } + + return errors +} + +/** + * 自定义 validator 约定: + * - 返回 true / undefined / Promise 解析为 true / undefined → 通过 + * - 返回 false → 使用 rule.message 或默认提示 + * - 返回非空字符串 → 该字符串作为错误消息 + */ +function runValidator(rule, { label, value }, staticErrors) { + const fallbackError = rule.message || (label ? `${label}校验未通过` : '校验未通过') + return Promise.resolve() + .then(() => rule.validator(value, rule)) + .then((verdict) => { + if (verdict === true || verdict === undefined || verdict === null) return staticErrors + if (verdict === false) return [...staticErrors, fallbackError] + return [...staticErrors, String(verdict)] + }) + .catch((error) => { + // validator 抛出的异常视为校验失败,避免整表提交被中断 + console.error('[form] validator 执行异常:', error) + return [...staticErrors, fallbackError] + }) +} + +/** + * 执行一组规则 + * @param {{ name?: string|number, label?: string, value?: any, rules?: Array }} options + * @returns {Promise} 错误消息数组,空数组表示通过 + */ +export function validateRules({ _name, label = '', value, rules = [] }) { + if (!Array.isArray(rules) || rules.length === 0) return Promise.resolve([]) + + return Promise.all( + rules.map((rule) => { + const staticErrors = validateStatic(rule, { label, value }) + if (typeof rule?.validator === 'function') + return runValidator(rule, { label, value }, staticErrors) + return Promise.resolve(staticErrors) + }), + ).then((groups) => groups.flat()) +} diff --git a/code/fms/fms-vue/src/components/ui/grid/col.vue b/code/fms/fms-vue/src/components/ui/grid/col.vue new file mode 100644 index 00000000..a3f72962 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/grid/col.vue @@ -0,0 +1,66 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/grid/index.scss b/code/fms/fms-vue/src/components/ui/grid/index.scss new file mode 100644 index 00000000..7e87ab72 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/grid/index.scss @@ -0,0 +1,130 @@ +// Grid 栅格样式(基础 24 栅格,不含响应式断点) +// 间距由 Row 通过 CSS 变量 --fms-grid-gutter-h / --fms-grid-gutter-v 下传 +@use 'sass:math'; + +$cols: 24; + +.fms-row { + display: flex; + flex-flow: row wrap; + width: 100%; // 防止被外层 flex 容器(如 .demo-panel)压缩/拉伸 + + // Row 默认提供 gutter 变量(纯数字;可被内联 style 覆盖) + --fms-grid-gutter-h: 0; + --fms-grid-gutter-v: 0; + + // 水平间距:负 margin 抵消 Col 的对称 padding(仅在 h>0 时有意义) + // 用 calc(var(--x) / 2):var 为纯数字时乘法合法;为 0 时 margin 为 0 + margin-left: calc(var(--fms-grid-gutter-h, 0) / -2); + margin-right: calc(var(--fms-grid-gutter-h, 0) / -2); + // 垂直间距:row-gap + row-gap: calc(var(--fms-grid-gutter-v, 0) * 1px); + + &-no-wrap { + flex-wrap: nowrap; + } + + &-align-top { + align-items: flex-start; + } + &-align-middle { + align-items: center; + } + &-align-bottom { + align-items: flex-end; + } + &-align-stretch { + align-items: stretch; + } + + &-justify-start { + justify-content: flex-start; + } + &-justify-end { + justify-content: flex-end; + } + &-justify-center { + justify-content: center; + } + &-justify-space-around { + justify-content: space-around; + } + &-justify-space-between { + justify-content: space-between; + } + &-justify-space-evenly { + justify-content: space-evenly; + } +} + +.fms-col { + min-width: 0; // 防止内容撑破 flex 列 + // Col 通过对称 padding 实现水平间距 + padding-left: calc(var(--fms-grid-gutter-h, 0) / 2); + padding-right: calc(var(--fms-grid-gutter-h, 0) / 2); + box-sizing: border-box; +} + +// 嵌套容器:抵消 Row 的负 margin,让内容视觉上留在父容器内(不出血) +// 用法:内容 +// 注:仅当父容器没有足够 padding 吸收负 margin 时才需要;本组件 demo 的 +// .demo-panel 自带 24px padding,已可吸收,一般无需使用。 +.fms-col-box { + margin-left: calc(var(--fms-grid-gutter-h, 0) / -2); + margin-right: calc(var(--fms-grid-gutter-h, 0) / -2); + width: calc(100% + var(--fms-grid-gutter-h, 0) * 1px); + box-sizing: border-box; +} + +// 24 栅格宽度 +@for $n from 0 through $cols { + .fms-col-#{$n} { + @if $n == 0 { + display: none; + } @else { + display: block; + } + flex: 0 0 math.percentage(math.div($n, $cols)); + max-width: math.percentage(math.div($n, $cols)); + } +} + +// offset 偏移 +@for $n from 0 through $cols { + .fms-col-offset-#{$n} { + margin-left: math.percentage(math.div($n, $cols)); + } +} + +// order +@for $n from 0 through $cols { + .fms-col-order-#{$n} { + order: $n; + } +} + +// push 右移(相对定位,使用 left) +@for $n from 0 through $cols { + .fms-col-push-#{$n} { + @if $n == 0 { + position: static; + left: auto; + } @else { + position: relative; + left: math.percentage(math.div($n, $cols)); + } + } +} + +// pull 左移(相对定位,使用 right) +@for $n from 0 through $cols { + .fms-col-pull-#{$n} { + @if $n == 0 { + position: static; + right: auto; + } @else { + position: relative; + right: math.percentage(math.div($n, $cols)); + } + } +} diff --git a/code/fms/fms-vue/src/components/ui/grid/row.vue b/code/fms/fms-vue/src/components/ui/grid/row.vue new file mode 100644 index 00000000..ff595238 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/grid/row.vue @@ -0,0 +1,60 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/input/index.scss b/code/fms/fms-vue/src/components/ui/input/index.scss new file mode 100644 index 00000000..0b551572 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/input/index.scss @@ -0,0 +1,212 @@ +.input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; + min-height: var(--fms-control-height); + padding: 0 var(--fms-control-padding-x); + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + transition: + border-color 150ms ease, + box-shadow 150ms ease; + + :deep(svg) { + width: 1em; + height: 1em; + } +} + +.input-wrapper:hover:not(.input-disabled) { + border-color: color-mix(in srgb, var(--fms-primary) 40%, var(--fms-border)); +} + +.input-wrapper.input-focused { + border-color: var(--fms-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent); +} + +/* 多行模式:紧凑的垂直布局,footer 只保留计数和手柄需要的空间 */ +.input-textarea { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 6px var(--fms-control-padding-x) 5px; +} + +.input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0; +} + +.textarea { + display: block; + width: 100%; + min-height: calc(var(--fms-line-height) * 3); + border: none; + outline: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0; + resize: none; + box-sizing: border-box; +} + +.input-textarea-with-footer .textarea { + padding-bottom: 16px; +} + +/* 校验状态 */ +.input-error { + border-color: var(--fms-danger); +} + +.input-error:hover:not(.input-disabled) { + border-color: color-mix(in srgb, var(--fms-danger) 70%, var(--fms-border)); +} + +.input-error.input-focused { + border-color: var(--fms-danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent); +} + +.input-warning { + border-color: var(--fms-warning); +} + +.input-warning:hover:not(.input-disabled) { + border-color: color-mix(in srgb, var(--fms-warning) 70%, var(--fms-border)); +} + +.input-warning.input-focused { + border-color: var(--fms-warning); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-warning) 20%, transparent); +} + +/* 禁用态:灰底 + 灰字 + 弱边框,悬停显示禁用光标 */ +.input-disabled { + background: var(--fms-disabled-bg); + color: var(--fms-disabled-text); + border-color: var(--fms-disabled-border); + cursor: not-allowed; +} + +.input:disabled, +.textarea:disabled { + cursor: not-allowed; +} + +/* 只读态:外观保持正常,仅用光标提示"可聚焦选中、不能编辑" */ +.input-readonly { + cursor: default; +} + +.input:read-only, +.textarea:read-only { + cursor: default; +} + +/* 前后缀 / 密码切换 / 清除按钮 */ +.input-prefix, +.input-suffix, +.password-toggle, +.input-clear { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--fms-text-secondary); +} + +.input-prefix { + margin-right: 6px; +} + +.input-suffix { + margin-left: 6px; +} + +.password-toggle, +.input-clear { + margin-left: 6px; + padding: 2px; + border: none; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + border-radius: 4px; + transition: + color 150ms ease, + background-color 150ms ease; +} + +.password-toggle:hover:not(:disabled), +.input-clear:hover { + color: var(--fms-text); + background: var(--fms-secondary-hover); +} + +.password-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 字数统计:单行模式下作为行内元素跟随在输入框右侧 */ +.input-count { + margin-left: 8px; + flex-shrink: 0; + font-size: 12px; + color: var(--fms-text-secondary); + white-space: nowrap; +} + +/* 多行 footer:字数统计 + 拖拽手柄,位于 textarea 下方,右对齐 */ +.textarea-footer { + position: absolute; + right: 4px; + bottom: 2px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 16px; +} + +.textarea-footer .input-count { + margin-left: 0; +} + +/* 自定义拖拽手柄:位于 footer 最右侧 */ +.input-resize-handle { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: ns-resize; + color: var(--fms-text-secondary); + user-select: none; + touch-action: none; + border-radius: 2px; +} + +.input-resize-handle:hover { + color: var(--fms-text); + background: var(--fms-secondary-hover); +} + +.input-resize-handle:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 1px; +} diff --git a/code/fms/fms-vue/src/components/ui/input/input.vue b/code/fms/fms-vue/src/components/ui/input/input.vue new file mode 100644 index 00000000..08054b45 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/input/input.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/message/index.scss b/code/fms/fms-vue/src/components/ui/message/index.scss new file mode 100644 index 00000000..64126430 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/message/index.scss @@ -0,0 +1,73 @@ +.fms-message { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 480px; + padding: 10px 14px; + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + box-shadow: var(--fms-popover-shadow); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + /* 子项恢复交互,覆盖容器的 pointer-events: none */ + pointer-events: auto; +} + +.fms-message-icon { + flex-shrink: 0; + width: var(--fms-feedback-icon-size); + height: var(--fms-feedback-icon-size); +} + +.fms-message-content { + flex: 1; + min-width: 0; + word-break: break-word; +} + +.fms-message-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.fms-message-close:hover { + background: var(--fms-secondary-hover); + color: var(--fms-text); +} + +.fms-message-close:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 类型配色:仅图标着色,复用现有主题语义色 */ +.fms-message-info .fms-message-icon { + color: var(--fms-primary); +} + +.fms-message-success .fms-message-icon { + color: var(--fms-success); +} + +.fms-message-warning .fms-message-icon { + color: var(--fms-warning); +} + +.fms-message-error .fms-message-icon { + color: var(--fms-danger); +} diff --git a/code/fms/fms-vue/src/components/ui/message/message-container.vue b/code/fms/fms-vue/src/components/ui/message/message-container.vue new file mode 100644 index 00000000..9c939daa --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/message/message-container.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/message/message-manager.js b/code/fms/fms-vue/src/components/ui/message/message-manager.js new file mode 100644 index 00000000..e83bea1a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/message/message-manager.js @@ -0,0 +1,52 @@ +import { createVNode, render } from 'vue' +import MessageContainer from './message-container.vue' +import { messageState } from './message-store.js' + +/* ---------- 单例容器 ---------- */ +let containerEl = null +let seed = 0 + +function ensureContainer() { + if (containerEl) return + containerEl = document.createElement('div') + document.body.appendChild(containerEl) + render(createVNode(MessageContainer), containerEl) +} + +/* ---------- 增删 ---------- */ +function open(options) { + ensureContainer() + const id = `fms-message-${++seed}` + messageState.items.push({ + id, + type: 'info', + content: '', + duration: 3000, + closable: false, + ...options, + // 每条消息记住自己的方位:优先用本次调用传入的,否则用全局默认(由 config 设置) + placement: (options && options.placement) || messageState.placement || 'top-center', + }) + return { close: () => remove(id) } +} + +function remove(id) { + const idx = messageState.items.findIndex((i) => i.id === id) + if (idx !== -1) messageState.items.splice(idx, 1) +} + +/* ---------- 命令式 API ---------- */ +export const Message = { + info: (content, opts) => open({ type: 'info', content, ...opts }), + success: (content, opts) => open({ type: 'success', content, ...opts }), + warning: (content, opts) => open({ type: 'warning', content, ...opts }), + error: (content, opts) => open({ type: 'error', content, ...opts }), + /** 关闭指定消息 */ + close: (id) => remove(id), + /** 全局配置:设置默认方位 */ + config: (opts) => { + if (opts && opts.placement) messageState.placement = opts.placement + }, +} + +export default Message diff --git a/code/fms/fms-vue/src/components/ui/message/message-store.js b/code/fms/fms-vue/src/components/ui/message/message-store.js new file mode 100644 index 00000000..50c06f3b --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/message/message-store.js @@ -0,0 +1,8 @@ +import { reactive } from 'vue' + +/* 全局共享的 Message 活跃列表:管理器写入、容器读取,保证响应式联动 */ +export const messageState = reactive({ + items: [], + /* 方位:top-left / top-center / top-right / center-left / center / center-right / bottom-left / bottom-center / bottom-right */ + placement: 'top-center', +}) diff --git a/code/fms/fms-vue/src/components/ui/message/message.vue b/code/fms/fms-vue/src/components/ui/message/message.vue new file mode 100644 index 00000000..5abdb885 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/message/message.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/modal/index.scss b/code/fms/fms-vue/src/components/ui/modal/index.scss new file mode 100644 index 00000000..7b850e89 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/modal/index.scss @@ -0,0 +1,243 @@ +/* ---------- 遮罩 ---------- */ +.fms-modal-mask { + position: fixed; + inset: 0; + z-index: var(--fms-z-modal); + background: var(--fms-mask-bg); +} + +/* 遮罩过渡:淡入淡出 */ +.fms-modal-mask-enter-active, +.fms-modal-mask-leave-active { + transition: opacity 160ms ease; +} + +.fms-modal-mask-enter-from, +.fms-modal-mask-leave-to { + opacity: 0; +} + +/* ---------- 外层容器:滚动 + 定位面板 ---------- */ +.fms-modal-wrap { + position: fixed; + inset: 0; + z-index: var(--fms-z-modal); + display: flex; + justify-content: center; + /* 默认距顶部 100px,内容超高时整体滚动 */ + align-items: flex-start; + padding: 100px 16px 24px; + overflow-y: auto; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; +} + +.fms-modal-centered { + align-items: center; + padding-top: 24px; +} + +/* ---------- 面板 ---------- */ +.fms-modal { + position: relative; + display: flex; + flex-direction: column; + flex-shrink: 0; + box-sizing: border-box; + max-width: 100%; + background: var(--fms-card); + border-radius: var(--fms-control-radius); + box-shadow: var(--fms-popover-shadow); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + /* 面板自身可聚焦(tabindex=-1),聚焦时给出清晰的焦点环 */ + outline: none; + padding: var(--fms-modal-padding); +} + +.fms-modal:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* ---------- 缩放手柄:四边边缘 ---------- */ +/* 命中区保持 6px 透明;拖拽线用 --fms-splitter-bar-size-hover 控制宽度,与 splitter 一致 */ +.fms-modal-resizer { + position: absolute; + touch-action: none; +} + +/* 拖拽线:hover / 拖拽时显示居中的灰色细线 */ +.fms-modal-resizer::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--fms-text-secondary); + opacity: 0; + transition: opacity 150ms ease; +} + +.fms-modal-resizer:hover::before, +.fms-modal-resizer.is-resizing::before { + opacity: 1; +} + +/* 左右边缘(e 右 / w 左)调整宽度 */ +.fms-modal-resizer-e, +.fms-modal-resizer-w { + top: 0; + bottom: 0; + width: 6px; + cursor: ew-resize; +} + +.fms-modal-resizer-e::before, +.fms-modal-resizer-w::before { + width: var(--fms-splitter-bar-size-hover); + height: 100%; +} + +.fms-modal-resizer-e { + right: 0; +} + +.fms-modal-resizer-w { + left: 0; +} + +/* 上下边缘(s 底 / n 顶)调整高度 */ +.fms-modal-resizer-s, +.fms-modal-resizer-n { + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; +} + +.fms-modal-resizer-s::before, +.fms-modal-resizer-n::before { + width: 100%; + height: var(--fms-splitter-bar-size-hover); +} + +.fms-modal-resizer-s { + bottom: 0; +} + +.fms-modal-resizer-n { + top: 0; +} + +/* ---------- 全屏态 ---------- */ +.fms-modal-fullscreen { + position: fixed; + inset: 0; + /* 覆盖 inline width(面板宽度),忽略 wrap 的内边距与拖拽位移 */ + width: 100vw !important; + height: 100vh; + max-width: none; + border-radius: 0; + box-shadow: none; + transform: none !important; +} + +/* ---------- 头部:标题 + 关闭按钮(也是拖拽手柄) ---------- */ +.fms-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + /* 拖拽手柄:触屏手势交由 pointer 事件处理、文本不可选中、悬停提示可拖动 */ + touch-action: none; + user-select: none; + cursor: move; +} + +.fms-modal-title { + margin: 0; + font-size: var(--fms-font-size); + font-weight: 600; + color: var(--fms-text); + word-break: break-word; +} + +/* 头部右侧按钮组:全屏切换 + 关闭 */ +.fms-modal-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.fms-modal-header-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.fms-modal-header-btn:hover { + background: var(--fms-secondary-hover); + color: var(--fms-text); +} + +.fms-modal-header-btn:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* ---------- 内容区 ---------- */ +.fms-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + color: var(--fms-text); + word-break: break-word; +} + +/* ---------- 底部:按钮右对齐 ---------- */ +.fms-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + flex-shrink: 0; +} + +/* ---------- 面板过渡:缩放淡入 ---------- */ +.fms-modal-enter-active, +.fms-modal-leave-active { + transition: + opacity 200ms ease, + transform 200ms ease; +} + +.fms-modal-enter-from, +.fms-modal-leave-to { + opacity: 0; + transform: scale(0.95); +} + +@media (prefers-reduced-motion: reduce) { + .fms-modal-enter-active, + .fms-modal-leave-active, + .fms-modal-mask-enter-active, + .fms-modal-mask-leave-active { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/modal/modal.vue b/code/fms/fms-vue/src/components/ui/modal/modal.vue new file mode 100644 index 00000000..c1b93c28 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/modal/modal.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/notification/index.scss b/code/fms/fms-vue/src/components/ui/notification/index.scss new file mode 100644 index 00000000..36e5538b --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/notification/index.scss @@ -0,0 +1,87 @@ +.fms-notification { + position: relative; + display: flex; + align-items: flex-start; + gap: 12px; + width: 360px; + max-width: calc(100vw - var(--fms-feedback-offset) * 2); + padding: 14px 16px; + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + box-shadow: var(--fms-popover-shadow); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + /* 子项恢复交互,覆盖容器的 pointer-events: none */ + pointer-events: auto; + overflow: hidden; +} + +.fms-notification-icon { + flex-shrink: 0; + margin-top: 1px; + width: var(--fms-feedback-icon-size); + height: var(--fms-feedback-icon-size); +} + +.fms-notification-success .fms-notification-icon { + color: var(--fms-success); +} +.fms-notification-warning .fms-notification-icon { + color: var(--fms-warning); +} +.fms-notification-error .fms-notification-icon { + color: var(--fms-danger); +} +.fms-notification-info .fms-notification-icon { + color: var(--fms-primary); +} + +.fms-notification-body { + flex: 1; + min-width: 0; +} + +.fms-notification-title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; + color: var(--fms-text); +} + +.fms-notification-desc { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--fms-text-secondary); + word-break: break-word; +} + +.fms-notification-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.fms-notification-close:hover { + background: var(--fms-secondary-hover); + color: var(--fms-text); +} + +.fms-notification-close:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} diff --git a/code/fms/fms-vue/src/components/ui/notification/notification-container.vue b/code/fms/fms-vue/src/components/ui/notification/notification-container.vue new file mode 100644 index 00000000..e2a2b1fd --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/notification/notification-container.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/notification/notification-manager.js b/code/fms/fms-vue/src/components/ui/notification/notification-manager.js new file mode 100644 index 00000000..ea64a69c --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/notification/notification-manager.js @@ -0,0 +1,54 @@ +import { createVNode, render } from 'vue' +import NotificationContainer from './notification-container.vue' +import { notificationState } from './notification-store.js' + +/* ---------- 单例容器 ---------- */ +let containerEl = null +let seed = 0 + +function ensureContainer() { + if (containerEl) return + containerEl = document.createElement('div') + document.body.appendChild(containerEl) + render(createVNode(NotificationContainer), containerEl) +} + +/* ---------- 增删 ---------- */ +function open(options) { + ensureContainer() + const id = `fms-notification-${++seed}` + notificationState.items.push({ + id, + type: 'info', + title: '', + description: '', + duration: 4500, + closable: true, + ...options, + // 每条通知记住自己的方位:优先用本次调用传入的,否则用全局默认(由 config 设置) + placement: (options && options.placement) || notificationState.placement || 'top-right', + }) + return { close: () => remove(id) } +} + +function remove(id) { + const idx = notificationState.items.findIndex((i) => i.id === id) + if (idx !== -1) notificationState.items.splice(idx, 1) +} + +function closeAll() { + notificationState.items.splice(0, notificationState.items.length) +} + +/* ---------- 命令式 API ---------- */ +export const Notification = { + open: (options) => open({ ...options }), + close: (id) => remove(id), + closeAll: () => closeAll(), + /** 全局配置:设置默认方位 */ + config: (opts) => { + if (opts && opts.placement) notificationState.placement = opts.placement + }, +} + +export default Notification diff --git a/code/fms/fms-vue/src/components/ui/notification/notification-store.js b/code/fms/fms-vue/src/components/ui/notification/notification-store.js new file mode 100644 index 00000000..7a2400c1 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/notification/notification-store.js @@ -0,0 +1,8 @@ +import { reactive } from 'vue' + +/* 全局共享的 Notification 活跃列表:管理器写入、容器读取,保证响应式联动 */ +export const notificationState = reactive({ + items: [], + /* 方位:top-left / top-center / top-right / center-left / center / center-right / bottom-left / bottom-center / bottom-right */ + placement: 'top-right', +}) diff --git a/code/fms/fms-vue/src/components/ui/notification/notification.vue b/code/fms/fms-vue/src/components/ui/notification/notification.vue new file mode 100644 index 00000000..7744a6e7 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/notification/notification.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/pagination/index.scss b/code/fms/fms-vue/src/components/ui/pagination/index.scss new file mode 100644 index 00000000..aa785769 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/pagination/index.scss @@ -0,0 +1,185 @@ +/* ============================================================ + * Pagination 分页 + * - 页码项为 32px 等宽方形按钮:默认白底描边,hover/焦点主色, + * 当前页主色填充 + 字重(不只靠颜色表达选中) + * - 省略号项 hover 切换双箭头图标,点击跳 ±5 页 + * 全部复用 --fms-* token,不硬编码具体数值。 + * ============================================================ */ + +.pagination { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + color: var(--fms-text); +} + +/* ---------- 总数 ---------- */ +.pagination-total { + margin-right: 4px; + color: var(--fms-text-secondary); + white-space: nowrap; +} + +.pagination-disabled .pagination-total { + color: var(--fms-disabled-text); +} + +/* ---------- 页码列表 ---------- */ +.pagination-list { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.pagination-item { + flex-shrink: 0; +} + +/* 页码项公共按钮:等宽方形,白底 1px 描边 */ +.pagination-page, +.pagination-ellipsis, +.pagination-prev, +.pagination-next { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--fms-control-height); + height: var(--fms-control-height); + padding: 0 4px; + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + background: var(--fms-card); + color: var(--fms-text-secondary); + font-family: inherit; + font-size: var(--fms-font-size); + line-height: 1; + cursor: pointer; + /* 颜色/背景不加过渡:点击时高亮即刻到位,避免渐变造成的「闪」感; + 仅保留按压缩放的过渡,提供明确的点击反馈 */ + transition: transform 150ms ease; +} + +/* 按压反馈:点击时有轻微缩放确认感(与 Button 一致) */ +.pagination-page:active:not(:disabled), +.pagination-ellipsis:active:not(:disabled), +.pagination-prev:active:not(:disabled), +.pagination-next:active:not(:disabled) { + transform: scale(0.96); +} + +/* 页码数字等宽,翻页不抖动 */ +.pagination-page { + font-variant-numeric: tabular-nums; +} + +.pagination-page:hover:not(:disabled), +.pagination-ellipsis:hover:not(:disabled), +.pagination-prev:hover:not(:disabled), +.pagination-next:hover:not(:disabled) { + border-color: var(--fms-primary); + color: var(--fms-primary); +} + +.pagination-page:focus-visible, +.pagination-ellipsis:focus-visible, +.pagination-prev:focus-visible, +.pagination-next:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 当前页:主色填充 + 字重,hover 保持原样(后定义覆盖通用 hover) */ +.pagination-page-current, +.pagination-page-current:hover:not(:disabled) { + background: var(--fms-primary); + border-color: var(--fms-primary); + color: var(--fms-primary-text); + font-weight: 600; +} + +/* 禁用:灰底灰字 not-allowed(原生 disabled 同时保证不可交互) */ +.pagination-page:disabled, +.pagination-ellipsis:disabled, +.pagination-prev:disabled, +.pagination-next:disabled { + background: var(--fms-disabled-bg); + border-color: var(--fms-disabled-border); + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +/* 图标按钮(prev / next / 省略号):无内边距,图标按字号缩放 */ +.pagination-prev, +.pagination-next, +.pagination-ellipsis { + padding: 0; +} + +.pagination-prev :deep(svg), +.pagination-next :deep(svg) { + width: 1em; + height: 1em; +} + +/* ---------- 省略号(跳 ±5 页) ---------- */ +/* 默认显示省略号,hover 切换为双箭头提示可跳转 */ +.pagination-ellipsis-more, +.pagination-ellipsis-jump { + width: 1em; + height: 1em; +} + +.pagination-ellipsis .pagination-ellipsis-more { + display: block; +} + +.pagination-ellipsis .pagination-ellipsis-jump { + display: none; +} + +.pagination-ellipsis:hover:not(:disabled) .pagination-ellipsis-more { + display: none; +} + +.pagination-ellipsis:hover:not(:disabled) .pagination-ellipsis-jump { + display: block; +} + +/* ---------- 每页条数切换 / 快速跳转 ---------- */ +.pagination-size-changer { + flex-shrink: 0; +} + +.pagination-jumper { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fms-text-secondary); + white-space: nowrap; +} + +/* 快速跳转输入框:限宽,数字输入框内部 input 自动填满 */ +.pagination-jumper-input { + box-sizing: border-box; + width: 48px; +} + +.pagination-disabled .pagination-jumper { + color: var(--fms-disabled-text); +} + +@media (prefers-reduced-motion: reduce) { + .pagination-page, + .pagination-ellipsis, + .pagination-prev, + .pagination-next { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/pagination/pagination.vue b/code/fms/fms-vue/src/components/ui/pagination/pagination.vue new file mode 100644 index 00000000..a7d8aaf9 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/pagination/pagination.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/popover/index.scss b/code/fms/fms-vue/src/components/ui/popover/index.scss new file mode 100644 index 00000000..f549f98e --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/popover/index.scss @@ -0,0 +1,120 @@ +.popover-trigger { + display: inline-flex; + /* grid 上下文下收缩到内容宽度,避免被同列最宽按钮撑大导致浮层居中偏移 */ + justify-self: start; +} + +/* 在完整轮廓上统一投影,让箭头和主体拥有连续的视觉重量。 */ +.popover { + position: fixed; + z-index: var(--fms-z-popover); + filter: drop-shadow(var(--fms-popover-shadow)); +} + +.popover-content { + position: relative; + padding: var(--fms-popover-padding); + background: var(--fms-card); + border-radius: var(--fms-control-radius); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); +} + +.popover-follow-trigger-width .popover-content { + box-sizing: border-box; + width: 100%; +} + +/* 与 Antdv Next 一致:箭头是浮层轮廓的一部分,不使用独立边框或阴影。 */ +.popover-arrow { + position: absolute; + width: 16px; + height: 16px; + z-index: 1; + display: block; + overflow: hidden; + pointer-events: none; +} + +.popover-arrow-content { + position: absolute; + inset-inline: 0; + bottom: 0; + width: 16px; + height: 8px; + background: var(--fms-card); + clip-path: polygon(0 100%, 50% 0, 100% 100%); +} + +/* 浮层在触发器上方,箭头在浮层下边,尖角朝下 */ +.popover-top .popover-arrow { + bottom: 0; + left: 50%; + transform: translateX(-50%) translateY(100%) rotate(180deg); +} + +/* 浮层在触发器下方,箭头在浮层上边,尖角朝上 */ +.popover-bottom .popover-arrow { + top: 0; + left: 50%; + transform: translateX(-50%) translateY(-100%); +} + +/* 浮层在触发器左侧,箭头在浮层右边,尖角朝右 */ +.popover-left .popover-arrow { + right: 0; + top: 50%; + transform: translateX(100%) translateY(-50%) rotate(90deg); +} + +/* 浮层在触发器右侧,箭头在浮层左边,尖角朝左 */ +.popover-right .popover-arrow { + left: 0; + top: 50%; + transform: translateX(-100%) translateY(-50%) rotate(-90deg); +} + +/* start / end 对齐时,箭头从居中移到靠近触发器边缘一侧 */ +.popover-top-start .popover-arrow, +.popover-bottom-start .popover-arrow { + left: 16px; +} + +.popover-top-end .popover-arrow, +.popover-bottom-end .popover-arrow { + left: auto; + right: 16px; +} + +.popover-left-start .popover-arrow, +.popover-right-start .popover-arrow { + top: 16px; +} + +.popover-left-end .popover-arrow, +.popover-right-end .popover-arrow { + top: auto; + bottom: 16px; +} + +/* 过渡只动 opacity / transform,低成本且支持 reduced-motion */ +.popover-enter-active, +.popover-leave-active { + transition: + opacity 0.15s ease, + transform 0.15s ease; +} + +.popover-enter-from, +.popover-leave-to { + opacity: 0; + transform: scale(0.95); +} + +@media (prefers-reduced-motion: reduce) { + .popover-enter-active, + .popover-leave-active { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/popover/popover.vue b/code/fms/fms-vue/src/components/ui/popover/popover.vue new file mode 100644 index 00000000..431a77a6 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/popover/popover.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/radio/index.scss b/code/fms/fms-vue/src/components/ui/radio/index.scss new file mode 100644 index 00000000..aeba9ca8 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/radio/index.scss @@ -0,0 +1,93 @@ +/* 单选组:水平流式布局,项间距 16px */ +.radio-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; +} + +.radio { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + cursor: pointer; + user-select: none; +} + +/* 视觉隐藏原生 input,保留可聚焦与同 name 方向键导航 */ +.radio-input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* 圆形视觉:选中时中心出现主色圆点 */ +.radio-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--fms-control-size); + height: var(--fms-control-size); + flex-shrink: 0; + border: 1px solid var(--fms-border); + border-radius: 50%; + background: var(--fms-card); + transition: + background-color 150ms ease, + border-color 150ms ease; +} + +/* 中心圆点:伪元素实现,避免内联 SVG */ +.radio-box::after { + content: ''; + /* 中心圆点直径 = 选框边长 × 0.5 */ + width: calc(var(--fms-control-size) * 0.5); + height: calc(var(--fms-control-size) * 0.5); + border-radius: 50%; + background: var(--fms-primary); + transform: scale(0); + transition: transform 150ms ease; +} + +/* 未选中悬停:边框贴近主色 */ +.radio:hover:not(.radio-disabled) .radio-box { + border-color: var(--fms-primary); +} + +/* 选中:主色边框 + 中心圆点 */ +.radio-checked .radio-box { + border-color: var(--fms-primary); +} + +.radio-checked .radio-box::after { + transform: scale(1); +} + +/* 键盘焦点投射到视觉圆 */ +.radio-input:focus-visible + .radio-box { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 禁用态:保留原配色整体降透明 */ +.radio-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@media (prefers-reduced-motion: reduce) { + .radio-box, + .radio-box::after { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/radio/radio-group.vue b/code/fms/fms-vue/src/components/ui/radio/radio-group.vue new file mode 100644 index 00000000..36d1b579 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/radio/radio-group.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/radio/radio.vue b/code/fms/fms-vue/src/components/ui/radio/radio.vue new file mode 100644 index 00000000..559a2515 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/radio/radio.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/select/index.scss b/code/fms/fms-vue/src/components/ui/select/index.scss new file mode 100644 index 00000000..87efe5c3 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/select/index.scss @@ -0,0 +1,207 @@ +/* Select 单选下拉 —— 触发器框体 + 下拉选项列表 */ + +.select { + display: inline-block; + vertical-align: middle; + min-width: 200px; + + /* Popover 触发器默认 inline-flex 会收缩到内容宽度, + 改为块级填满 .select,保证触发器与下拉宽度一致 */ + :deep(.popover-trigger) { + display: block; + width: 100%; + } +} + +.select-trigger { + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + width: 100%; + min-height: var(--fms-control-height); + padding: 0 var(--fms-control-padding-x); + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + cursor: pointer; + transition: + border-color 150ms ease, + box-shadow 150ms ease; + + :deep(svg) { + width: 1em; + height: 1em; + } +} + +.select-trigger:hover { + border-color: color-mix(in srgb, var(--fms-primary) 40%, var(--fms-border)); +} + +.select-trigger-open, +.select-trigger-focused { + border-color: var(--fms-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent); +} + +/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入 */ +.select-input { + flex: 1; + min-width: 0; + margin: 0; + padding: 0; + border: none; + outline: none; + background: transparent; + color: inherit; + font: inherit; + line-height: var(--fms-control-height); + text-overflow: ellipsis; +} + +.select-input:disabled { + cursor: not-allowed; +} + +.select-arrow { + flex-shrink: 0; + color: var(--fms-text-secondary); + transition: transform 150ms ease; +} + +.select-arrow-open { + transform: rotate(180deg); +} + +.select-loading { + flex-shrink: 0; + color: var(--fms-text-secondary); + animation: select-loading-spin 1s linear infinite; +} + +@keyframes select-loading-spin { + to { + transform: rotate(360deg); + } +} + +.select-clear { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 2px; + border: none; + background: transparent; + color: var(--fms-text-secondary); + cursor: pointer; + border-radius: 4px; + transition: + color 150ms ease, + background-color 150ms ease; + + :deep(svg) { + width: 1em; + height: 1em; + } +} + +.select-clear:hover { + color: var(--fms-text); + background: var(--fms-secondary-hover); +} + +.select-clear:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 1px; +} + +/* 禁用态:灰底灰字弱边框,悬停不响应 */ +.select-disabled { + cursor: not-allowed; +} + +.select-disabled .select-trigger { + background: var(--fms-disabled-bg); + color: var(--fms-disabled-text); + border-color: var(--fms-disabled-border); + cursor: not-allowed; +} + +.select-disabled .select-trigger:hover { + border-color: var(--fms-disabled-border); +} + +/* 下拉容器:宽度由 Popover follow-trigger-width 严格匹配触发器, + 此处 width:100% 让列表填满 popover-content */ +.select-dropdown { + width: 100%; + outline: none; +} + +/* 选项列表:超高滚动,宽度跟随浮层 */ +.select-list { + max-height: var(--fms-select-dropdown-max-height, 256px); + overflow-y: auto; + padding: 4px; +} + +/* 无匹配项时的占位提示 */ +.select-empty { + padding: 8px; + text-align: center; + color: var(--fms-text-secondary); + font-size: var(--fms-font-size); +} + +.select-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 8px; + border-radius: 4px; + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + cursor: pointer; + /* 选项文案允许换行,宽度跟随浮层而非撑破它 */ + white-space: normal; + word-break: break-word; + transition: background-color 150ms ease; +} + +.select-option:hover:not(.select-option-disabled), +.select-option-active { + background: var(--fms-secondary-hover); +} + +.select-option-selected { + color: var(--fms-primary); +} + +.select-option-selected .select-option-check { + color: var(--fms-primary); +} + +.select-option-check { + flex-shrink: 0; + display: block; + width: 14px; + height: 14px; +} + +.select-option-disabled { + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +@media (prefers-reduced-motion: reduce) { + .select-loading { + animation: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/select/select.vue b/code/fms/fms-vue/src/components/ui/select/select.vue new file mode 100644 index 00000000..664b420f --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/select/select.vue @@ -0,0 +1,525 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/spin/index.scss b/code/fms/fms-vue/src/components/ui/spin/index.scss new file mode 100644 index 00000000..a2a11ce9 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/spin/index.scss @@ -0,0 +1,85 @@ +/* Spin 加载中 —— 形态一:独立指示器;形态二:包裹内容 + 轻量遮罩 */ + +/* 根:形态一默认 inline-flex 指示器区 */ +.spin { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--fms-primary); + vertical-align: middle; +} + +/* 形态二:块级 + 相对定位,颜色交还内容(不改内部布局) */ +.spin-nested { + display: block; + position: relative; + color: inherit; +} + +/* 内容容器:只做相对定位承载遮罩层,不改变内部元素位置/尺寸 */ +.spin-container { + position: relative; +} + +/* 加载中:内容降透明、禁交互;::after 遮罩铺满(绝对定位,不占文档流) */ +.spin-spinning .spin-container { + opacity: 0.5; + user-select: none; + pointer-events: none; +} + +.spin-spinning .spin-container::after { + content: ''; + position: absolute; + inset: 0; + z-index: 10; + background: color-mix(in srgb, var(--fms-text) 6%, transparent); +} + +/* 指示器区:形态一为 inline 指示器区;形态二绝对居中于遮罩之上 */ +.spin-section { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: var(--fms-spin-description-gap, 8px); + color: var(--fms-primary); +} + +.spin-nested .spin-section { + position: absolute; + top: 50%; + left: 50%; + z-index: 11; + transform: translate(-50%, -50%); + pointer-events: none; +} + +/* 指示器图标 */ +.spin-indicator { + flex-shrink: 0; + width: var(--fms-spin-size, 20px); + height: var(--fms-spin-size, 20px); + animation: spin-rotate 800ms linear infinite; +} + +/* 文案 */ +.spin-description { + color: var(--fms-text-secondary); + font-size: 12px; + line-height: 16px; + text-align: center; + white-space: nowrap; +} + +@keyframes spin-rotate { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spin-indicator { + animation: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/spin/spin.vue b/code/fms/fms-vue/src/components/ui/spin/spin.vue new file mode 100644 index 00000000..481f9faa --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/spin/spin.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/splitter/index.scss b/code/fms/fms-vue/src/components/ui/splitter/index.scss new file mode 100644 index 00000000..03cad5e3 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/splitter/index.scss @@ -0,0 +1,143 @@ +/* ---------- 容器 ---------- */ +.fms-splitter { + display: flex; + width: 100%; + height: 100%; + align-items: stretch; +} + +.fms-splitter-horizontal { + flex-direction: row; +} + +.fms-splitter-vertical { + flex-direction: column; +} + +/* ---------- 面板 ---------- */ +.fms-splitter-panel { + overflow: auto; + box-sizing: border-box; + /* 允许 flex 子项收缩,避免内容撑破布局 */ + min-width: 0; + min-height: 0; +} + +/* ---------- 分隔条 ---------- */ +.fms-splitter-bar { + position: relative; + flex: none; + touch-action: none; + user-select: none; +} + +/* 命中区:6px;水平方向调整宽度,垂直方向调整高度 */ +.fms-splitter-horizontal .fms-splitter-bar { + width: var(--fms-splitter-trigger-size); + cursor: col-resize; +} + +.fms-splitter-vertical .fms-splitter-bar { + height: var(--fms-splitter-trigger-size); + cursor: row-resize; +} + +/* 视觉条:居中 2px 细线 */ +.fms-splitter-bar::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--fms-border); + transition: + opacity 150ms ease, + background-color 150ms ease; +} + +.fms-splitter-horizontal .fms-splitter-bar::before { + width: var(--fms-splitter-bar-size); + height: 100%; +} + +.fms-splitter-vertical .fms-splitter-bar::before { + width: 100%; + height: var(--fms-splitter-bar-size); +} + +/* 中央手柄:hover/聚焦/拖拽时高亮,提示可拖 */ +.fms-splitter-bar-grip { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--fms-text-secondary); + border-radius: 2px; + opacity: 0; + transition: + opacity 150ms ease, + background-color 150ms ease; +} + +.fms-splitter-horizontal .fms-splitter-bar-grip { + width: var(--fms-splitter-bar-size); + height: var(--fms-splitter-handle-size); +} + +.fms-splitter-vertical .fms-splitter-bar-grip { + width: var(--fms-splitter-handle-size); + height: var(--fms-splitter-bar-size); +} + +.fms-splitter-bar:hover .fms-splitter-bar-grip, +.fms-splitter-bar-active .fms-splitter-bar-grip, +.fms-splitter-bar:focus-visible .fms-splitter-bar-grip { + opacity: 1; +} + +.fms-splitter-bar:hover::before, +.fms-splitter-bar-active::before, +.fms-splitter-bar:focus-visible::before { + background: var(--fms-text-secondary); +} + +/* hover 模式(barOnHover):分割线平时隐藏,hover / 聚焦 / 拖拽时才显示。 + 线宽固定为 --fms-splitter-bar-size-hover(居中,略宽于命中区), + 命中区保持 6px 不变,拖拽热区不随之扩大 */ +.fms-splitter-bar-hover::before { + background: var(--fms-text-secondary); + opacity: 0; +} + +.fms-splitter-horizontal .fms-splitter-bar-hover::before { + width: var(--fms-splitter-bar-size-hover); + height: 100%; +} + +.fms-splitter-vertical .fms-splitter-bar-hover::before { + width: 100%; + height: var(--fms-splitter-bar-size-hover); +} + +.fms-splitter-bar-hover .fms-splitter-bar-grip { + display: none; +} + +.fms-splitter-bar-hover:hover::before, +.fms-splitter-bar-hover.fms-splitter-bar-active::before, +.fms-splitter-bar-hover:focus-visible::before { + opacity: 1; +} + +/* 键盘焦点 */ +.fms-splitter-bar:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: -2px; +} + +@media (prefers-reduced-motion: reduce) { + .fms-splitter-bar::before, + .fms-splitter-bar-grip { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/splitter/panel.vue b/code/fms/fms-vue/src/components/ui/splitter/panel.vue new file mode 100644 index 00000000..4b200f57 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/splitter/panel.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue new file mode 100644 index 00000000..ea2a0789 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/switch/index.scss b/code/fms/fms-vue/src/components/ui/switch/index.scss new file mode 100644 index 00000000..6a77a81a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/switch/index.scss @@ -0,0 +1,80 @@ +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + border: none; + background: transparent; + padding: 0; + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + cursor: pointer; + user-select: none; +} + +/* 胶囊轨道:尺寸由 token 控制 */ +.switch-track { + position: relative; + display: inline-block; + flex-shrink: 0; + width: var(--fms-switch-width); + height: var(--fms-switch-height); + border-radius: 999px; + background: var(--fms-secondary); + transition: background-color 150ms ease; +} + +/* 滑块:位于轨道左上角,选中时向右平移到右下角 */ +.switch-thumb { + position: absolute; + top: var(--fms-switch-thumb-offset); + left: var(--fms-switch-thumb-offset); + width: var(--fms-switch-thumb-size); + height: var(--fms-switch-thumb-size); + border-radius: 50%; + background: var(--fms-card); + box-shadow: var(--fms-shadow); + transition: transform 150ms ease; +} + +/* 选中态:主色轨道 + 滑块位移(轨道宽 - 滑块直径 - 2 × 边距) */ +.switch-checked .switch-track { + background: var(--fms-primary); +} + +.switch-checked .switch-thumb { + transform: translateX( + calc( + var(--fms-switch-width) - var(--fms-switch-thumb-size) - + var(--fms-switch-thumb-offset) - var(--fms-switch-thumb-offset) + ) + ); +} + +/* 悬停:轨道略微加深 / 主色 hover */ +.switch:hover:not(.switch-disabled) .switch-track { + background: color-mix(in srgb, var(--fms-secondary) 88%, var(--fms-text)); +} + +.switch-checked:hover:not(.switch-disabled) .switch-track { + background: var(--fms-primary-hover); +} + +/* 键盘焦点 */ +.switch:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 禁用态:保留原配色整体降透明 */ +.switch-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +@media (prefers-reduced-motion: reduce) { + .switch-track, + .switch-thumb { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/switch/switch.vue b/code/fms/fms-vue/src/components/ui/switch/switch.vue new file mode 100644 index 00000000..aa625e9a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/switch/switch.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tabs/index.scss b/code/fms/fms-vue/src/components/ui/tabs/index.scss new file mode 100644 index 00000000..08b5e90a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tabs/index.scss @@ -0,0 +1,225 @@ +/* ============================================================ + * Tabs 标签页 + * - top:标签横排在上,底部 1px 分隔线,激活项 2px 主色下指示条 + * - left / right:标签纵向排列在左右,激活项主色侧指示条 + 浅背景 + * 全部复用 --fms-* token,不硬编码具体数值。 + * ============================================================ */ + +.tabs { + display: flex; + width: 100%; + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + color: var(--fms-text); +} + +/* ---------- 布局 ---------- */ +.tabs-top { + flex-direction: column; +} + +.tabs-left, +.tabs-right { + flex-direction: row; + align-items: stretch; +} + +/* ---------- 标签栏 ---------- */ +.tabs-nav { + display: flex; + flex-shrink: 0; +} + +/* tab 列表:承担方向排列、间距与滚动,flex: 1 占满剩余空间, + * 将 leftExtra / rightExtra 推到标签栏两端而非紧贴标签 */ +.tabs-nav-list { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +.tabs-top .tabs-nav { + flex-direction: row; + align-items: flex-end; + overflow-x: auto; + border-bottom: 1px solid var(--fms-border); +} + +.tabs-top .tabs-nav-list { + flex-direction: row; + align-items: flex-end; + gap: 8px; +} + +.tabs-left .tabs-nav { + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--fms-border); +} + +.tabs-left .tabs-nav-list { + flex-direction: column; + gap: 8px; +} + +.tabs-right .tabs-nav { + flex-direction: column; + overflow-y: auto; + border-left: 1px solid var(--fms-border); +} + +.tabs-right .tabs-nav-list { + flex-direction: column; + gap: 8px; +} + +/* 居中模式:标签列表不拉伸,靠 margin auto 在标签栏内居中 + * (leftExtra / rightExtra 仍留在两端) */ +.tabs-centered .tabs-nav-list { + flex: 0 1 auto; +} + +.tabs-top.tabs-centered .tabs-nav-list { + margin-left: auto; + margin-right: auto; +} + +.tabs-left.tabs-centered .tabs-nav-list, +.tabs-right.tabs-centered .tabs-nav-list { + margin-top: auto; + margin-bottom: auto; +} + +/* 标签栏两端额外内容(leftExtra / rightExtra 插槽) */ +.tabs-nav-extra { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.tabs-top .tabs-nav-extra { + /* 上下对称 margin 撑高标签栏,附加内容在标签栏内垂直居中, + * 同时底部留空避免紧贴分割线 */ + align-self: center; + margin-top: 4px; + margin-bottom: 4px; +} + +.tabs-top .tabs-nav-extra:first-child { + margin-right: 8px; +} + +.tabs-top .tabs-nav-extra:last-child { + margin-left: 8px; +} + +.tabs-left .tabs-nav-extra:first-child, +.tabs-right .tabs-nav-extra:first-child { + margin-bottom: 8px; +} + +.tabs-left .tabs-nav-extra:last-child, +.tabs-right .tabs-nav-extra:last-child { + margin-top: 8px; +} + +/* ---------- 标签项 ---------- */ +.tabs-tab { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-shrink: 0; + height: var(--fms-control-height); + padding: 0 var(--fms-control-padding-x); + border: none; + background: transparent; + color: var(--fms-text-secondary); + font-family: inherit; + font-size: var(--fms-font-size); + line-height: var(--fms-line-height); + white-space: nowrap; + cursor: pointer; + transition: color 150ms ease; +} + +.tabs-tab:hover:not(:disabled) { + color: var(--fms-primary); +} + +.tabs-tab:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 激活:指示条 + 主色文字 + 字重,不只依赖颜色表达选中 */ +.tabs-tab-active { + color: var(--fms-primary); + font-weight: 600; +} + +/* line 指示条:top 为下边框,left/right 为侧边框,无需 JS 测量 */ +.tabs-top .tabs-tab { + border-bottom: 2px solid transparent; +} + +.tabs-top .tabs-tab-active { + border-bottom-color: var(--fms-primary); +} + +.tabs-left .tabs-tab, +.tabs-right .tabs-tab { + border-top: none; + border-bottom: none; +} + +.tabs-left .tabs-tab { + border-right: 2px solid transparent; +} + +.tabs-left .tabs-tab-active { + border-right-color: var(--fms-primary); + background: var(--fms-secondary-hover); +} + +.tabs-right .tabs-tab { + border-left: 2px solid transparent; +} + +.tabs-right .tabs-tab-active { + border-left-color: var(--fms-primary); + background: var(--fms-secondary-hover); +} + +/* 禁用:降透明 + not-allowed(原生 disabled 同时保证不可交互) */ +.tabs-tab:disabled { + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +/* ---------- 内容区 ---------- */ +.tabs-content { + min-width: 0; +} + +.tabs-top .tabs-content { + padding-top: 16px; +} + +.tabs-left .tabs-content { + flex: 1 1 auto; + padding-left: 16px; +} + +.tabs-right .tabs-content { + flex: 1 1 auto; + padding-right: 16px; +} + +@media (prefers-reduced-motion: reduce) { + .tabs-tab { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/tabs/tab-pane.vue b/code/fms/fms-vue/src/components/ui/tabs/tab-pane.vue new file mode 100644 index 00000000..c2225090 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tabs/tab-pane.vue @@ -0,0 +1,18 @@ + + + diff --git a/code/fms/fms-vue/src/components/ui/tabs/tabs.vue b/code/fms/fms-vue/src/components/ui/tabs/tabs.vue new file mode 100644 index 00000000..04968fc1 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tabs/tabs.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tag/index.scss b/code/fms/fms-vue/src/components/ui/tag/index.scss new file mode 100644 index 00000000..03e6bc9b --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tag/index.scss @@ -0,0 +1,124 @@ +/* Tag 标签 —— 胶囊小标签(样式参考 shadcn Badge) */ + +/* 基础:inline-flex 内容撑开、全圆角胶囊、12px 中粗字重 */ +.tag { + display: inline-flex; + align-items: center; + gap: var(--fms-tag-gap); + box-sizing: border-box; + height: var(--fms-tag-height); + padding: 0 var(--fms-tag-padding-x); + border: 1px solid transparent; + border-radius: calc(var(--fms-tag-height) / 2); + font-size: var(--fms-tag-font-size); + font-weight: 500; + line-height: 1; + white-space: nowrap; + text-decoration: none; + transition: + background-color 150ms ease, + color 150ms ease, + border-color 150ms ease, + opacity 150ms ease; +} + +/* 变体:default 主色实底 */ +.tag-default { + background: var(--fms-primary); + color: var(--fms-primary-text); +} + +/* 变体:secondary 中性浅底 */ +.tag-secondary { + background: var(--fms-secondary); + color: var(--fms-text); +} + +/* 变体:outline 无底描边 */ +.tag-outline { + background: transparent; + border-color: var(--fms-border); + color: var(--fms-text); +} + +/* 变体:danger 危险淡底红字(参考 shadcn destructive) */ +.tag-danger { + background: color-mix(in srgb, var(--fms-danger) 10%, transparent); + color: var(--fms-danger); +} + +/* 变体:ghost 无底,hover 出浅底 */ +.tag-ghost { + background: transparent; + color: var(--fms-text); +} + +/* 链接标签(href 渲染 ):hover 加深、键盘焦点清晰,表示可点击 */ +a.tag:hover { + background: color-mix(in srgb, currentColor 8%, transparent); +} + +.tag:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; +} + +/* 图标:1em 跟随字号;插槽内容带父组件 scoped 属性,需 :deep 穿透 */ +.tag-icon :deep(svg), +.tag-close :deep(svg) { + flex-shrink: 0; + width: 1em; + height: 1em; +} + +/* 关闭按钮:圆点命中区、灰色弱化、hover 加深 */ +.tag-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1em; + height: 1em; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.7; + transition: opacity 150ms ease; +} + +.tag-close:hover { + opacity: 1; +} + +.tag-close:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 1px; +} + +/* 禁用态:整标签置灰不可交互,覆盖变体背景 */ +.tag-disabled, +.tag-disabled:hover { + background: var(--fms-disabled-bg); + border-color: var(--fms-disabled-border); + color: var(--fms-disabled-text); + cursor: not-allowed; +} + +.tag-disabled .tag-close { + cursor: not-allowed; + opacity: 0.4; +} + +/* 关闭后隐藏(与 antdv 一致:close 后标签自身消失) */ +.tag-hidden { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .tag, + .tag-close { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/tag/tag.vue b/code/fms/fms-vue/src/components/ui/tag/tag.vue new file mode 100644 index 00000000..28a6a7a8 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tag/tag.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tooltip/index.scss b/code/fms/fms-vue/src/components/ui/tooltip/index.scss new file mode 100644 index 00000000..42934bc7 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tooltip/index.scss @@ -0,0 +1,8 @@ +/* Tooltip —— Popover 的 hover 薄封装 + 浮层背景 / 圆角 / 定位 / 箭头 / 过渡全部复用 Popover, + 这里只对纯文本内容做最小微调。 */ + +.tooltip-text { + /* 允许按 \n 换行;超宽由 Popover 的 max-width 约束自动折行 */ + white-space: pre-line; +} diff --git a/code/fms/fms-vue/src/components/ui/tooltip/tooltip.vue b/code/fms/fms-vue/src/components/ui/tooltip/tooltip.vue new file mode 100644 index 00000000..36cd65a3 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tooltip/tooltip.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tree/index.scss b/code/fms/fms-vue/src/components/ui/tree/index.scss new file mode 100644 index 00000000..6706606a --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tree/index.scss @@ -0,0 +1,245 @@ +/* Tree 组件样式:缩进、悬停/聚焦、选中、禁用、复选半选、拖拽指示线、过滤高亮、loading。 + 尺寸/间距/圆角/字号/阴影统一引用 --fms-* token,不写死数值。 */ + +.tree { + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: 1.5; + outline: none; +} + +/* 单节点行:padding-left 为「固定左留白 + 层级缩进」;flex 布局承载 switcher/checkbox/title/grip */ +.tree-node { + position: relative; + display: flex; + align-items: center; + min-height: var(--fms-tree-node-height); + padding-left: calc(var(--fms-tree-gutter-x) + var(--fms-tree-indent) * var(--level)); + border-radius: var(--fms-control-radius); + cursor: default; + user-select: none; + transition: + background-color 0.15s ease, + color 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.tree-node:hover:not(.is-disabled) { + background: var(--fms-secondary-hover); + cursor: pointer; +} + +/* 键盘/鼠标聚焦:focus-visible 描边,避免鼠标点击也出现描边 */ +.tree-node:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: -2px; +} + +/* 选中:主色淡底 + 主色加粗文字(不依赖单一颜色表达) */ +.tree-node.is-selected { + background: color-mix(in srgb, var(--fms-primary) 6%, transparent); +} +.tree-node.is-selected .tree-node-content { + color: var(--fms-primary); + font-weight: 600; +} + +.tree-node.is-disabled, +.tree-node.is-disabled .tree-node-content { + color: var(--fms-disabled-text); + cursor: not-allowed; +} +.tree-node.is-checkbox-disabled .tree-node-content { + opacity: 0.7; +} + +/* 拖拽源:半透明提示 */ +.tree-node.is-dragging { + opacity: 0.4; +} + +/* ---------- 展开/收起图标 ---------- */ +.tree-node-switcher { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: var(--fms-tree-switcher-size); + height: var(--fms-tree-switcher-size); + margin-right: 2px; + color: var(--fms-text-secondary); + border-radius: 50%; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} +.tree-node-switcher:hover:not(.is-placeholder) { + background: color-mix(in srgb, var(--fms-primary) 10%, transparent); + color: var(--fms-primary); +} +.tree-node-switcher.is-placeholder { + cursor: default; +} +.tree-switcher-icon { + width: var(--fms-tree-icon-size); + height: var(--fms-tree-icon-size); + transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.4, 1); +} +.tree-switcher-icon.is-open { + transform: rotate(90deg); +} +.tree-spinner { + width: var(--fms-tree-icon-size); + height: var(--fms-tree-icon-size); + color: var(--fms-primary); + animation: tree-spin 0.8s linear infinite; +} +@keyframes tree-spin { + to { + transform: rotate(360deg); + } +} + +/* ---------- 复选框 ---------- */ +.tree-node-checkbox { + display: inline-flex; + align-items: center; + flex: none; + margin-right: 6px; +} + +/* ---------- 标题 ---------- */ +.tree-node-content { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + height: 100%; + padding: 0 var(--fms-tree-title-padding-x); + border-radius: var(--fms-control-radius); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.tree-node.is-block .tree-node-content { + flex: 1 1 auto; +} +.tree-highlight { + background: color-mix(in srgb, var(--fms-primary) 22%, transparent); + color: var(--fms-primary); + font-weight: 600; + border-radius: 2px; + padding: 0 1px; +} + +/* ---------- 拖拽手柄(在最左侧) ---------- */ +.tree-node-grip { + display: inline-flex; + align-items: center; + flex: none; + width: var(--fms-tree-grip-size); + height: var(--fms-tree-grip-size); + margin-right: 4px; + color: var(--fms-text-secondary); + cursor: grab; + opacity: 0; + transition: opacity 0.15s; +} +.tree-node:hover .tree-node-grip { + opacity: 1; +} +.tree-node-grip:active { + cursor: grabbing; +} + +/* ---------- 拖拽落点指示:圆点 + 连接线 ---------- */ +.tree-node.drop-before::before, +.tree-node.drop-after::before { + content: ''; + position: absolute; + left: calc(var(--fms-tree-gutter-x) + var(--fms-tree-indent) * var(--level)); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--fms-primary); + box-shadow: 0 0 0 2px var(--fms-card); + z-index: 1; +} +.tree-node.drop-before::before { + top: -4px; +} +.tree-node.drop-after::before { + bottom: -4px; +} +.tree-node.drop-before::after, +.tree-node.drop-after::after { + content: ''; + position: absolute; + left: calc(var(--fms-tree-gutter-x) + var(--fms-tree-indent) * var(--level) + 8px); + right: 0; + height: var(--fms-tree-drop-line-size); + background: var(--fms-primary); + border-radius: var(--fms-tree-drop-line-size); +} +.tree-node.drop-before::after { + top: -1px; +} +.tree-node.drop-after::after { + bottom: -1px; +} +.tree-node.drop-inside { + background: color-mix(in srgb, var(--fms-primary) 12%, transparent); + outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent); + outline-offset: -2px; +} + +/* ---------- 展开/收起动画(grid-template-rows 高度过渡) ---------- */ +.tree-node-children { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 1fr; + overflow: hidden; +} +.tree-collapse-enter-active, +.tree-collapse-leave-active { + transition: + grid-template-rows 0.22s cubic-bezier(0.2, 0.8, 0.4, 1), + opacity 0.18s ease; +} +.tree-collapse-enter-from, +.tree-collapse-leave-to { + grid-template-rows: 0fr; + opacity: 0; +} +.tree-node-children-inner { + overflow: hidden; +} + +/* ---------- 空态 ---------- */ +.tree-empty { + padding: 12px 8px; + color: var(--fms-text-secondary); + text-align: center; +} + +.tree-disabled .tree-node { + cursor: not-allowed; +} + +/* 尊重系统「减少动态效果」偏好 */ +@media (prefers-reduced-motion: reduce) { + .tree-node, + .tree-switcher-icon, + .tree-node-grip, + .tree-spinner, + .tree-node-children { + transition: none; + animation: none; + } + .tree-collapse-enter-active, + .tree-collapse-leave-active { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/components/ui/tree/tree-node.vue b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue new file mode 100644 index 00000000..6ce16f9c --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tree/tree-node.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tree/tree.vue b/code/fms/fms-vue/src/components/ui/tree/tree.vue new file mode 100644 index 00000000..15dae0fd --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tree/tree.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/ui/tree/utils.js b/code/fms/fms-vue/src/components/ui/tree/utils.js new file mode 100644 index 00000000..e702daa6 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/tree/utils.js @@ -0,0 +1,155 @@ +// 纯函数集合:不依赖组件实例,仅做树结构与联动推导。 +// 字段名通过 fieldNames 兼容自定义(默认 { key, title, children }), +// 这样上层既能用默认字段,也能对接后端返回的不同字段命名。 + +export function resolveFieldNames(fieldNames) { + return { + key: 'key', + title: 'title', + children: 'children', + ...fieldNames, + } +} + +export function getNodeKey(node, fn) { + return node[fn.key] +} + +export function getNodeTitle(node, fn) { + return node[fn.title] +} + +export function getNodeChildren(node, fn) { + const children = node[fn.children] + return Array.isArray(children) && children.length ? children : null +} + +// 叶子判定: +// - 显式 isLeaf === true -> 叶子(不再展开) +// - 显式 isLeaf === false -> 非叶子(即使暂无 children,也允许展开触发异步加载) +// - 未指定时:非 lazy 看 children;lazy 看是否已加载且无 children +// 未加载完成的懒加载节点一律视作可展开(非叶子),以便触发 load 事件。 +export function isLeafNode(node, fn, { lazy = false, loadedKeys = new Set() } = {}) { + if (node.isLeaf === true) return true + if (node.isLeaf === false) return false + const children = getNodeChildren(node, fn) + if (lazy) { + return loadedKeys.has(getNodeKey(node, fn)) && !children + } + return !children +} + +// 扁平化:提取父/子/层级/节点映射,供展开路径回溯、勾选联动与键盘导航统一复用。 +// 仅由 treeData 推导,props 变化即重算(computed 缓存)。 +export function buildFlat(treeData, fn) { + const parentMap = new Map() + const levelMap = new Map() + const nodeMap = new Map() + const childrenMap = new Map() + const rootKeys = [] + const walk = (list, parentKey, level) => { + list.forEach((node) => { + const key = getNodeKey(node, fn) + nodeMap.set(key, node) + parentMap.set(key, parentKey) + levelMap.set(key, level) + const children = getNodeChildren(node, fn) + childrenMap.set(key, children ? children.map((c) => getNodeKey(c, fn)) : []) + if (children) walk(children, key, level + 1) + }) + } + const list = Array.isArray(treeData) ? treeData : [] + walk(list, null, 0) + list.forEach((n) => rootKeys.push(getNodeKey(n, fn))) + return { parentMap, levelMap, nodeMap, childrenMap, rootKeys } +} + +// 收集某节点全部子孙 key(含自身),用于勾选联动时整体置位/清除。 +export function collectDescendants(rootKey, childrenMap) { + const out = [rootKey] + const stack = [...(childrenMap.get(rootKey) || [])] + while (stack.length) { + const k = stack.pop() + out.push(k) + const c = childrenMap.get(k) + if (c) stack.push(...c) + } + return out +} + +// 回溯祖先链:返回 targetKeys 的所有祖先 key(用于自动展开并展开命中路径)。 +export function getParentKeys(targetKeys, parentMap) { + const out = new Set() + targetKeys.forEach((key) => { + let parent = parentMap.get(key) + while (parent != null) { + out.add(parent) + parent = parentMap.get(parent) + } + }) + return [...out] +} + +// 勾选联动:根据显式勾选集合推导完整 checked 与半选 half。 +// 非严格模式下父节点勾选会级联子孙;子节点变化会回推父节点为半选或全选。 +export function conductCheck(treeData, explicitChecked, fn) { + const checked = new Set(explicitChecked) + const half = new Set() + // 先把显式勾选的子孙全部置为勾选,保证父节点为全选时子孙全选 + const flat = buildFlat(treeData, fn) + explicitChecked.forEach((key) => { + collectDescendants(key, flat.childrenMap).forEach((k) => checked.add(k)) + }) + const walk = (list) => { + return list.map((node) => { + const key = getNodeKey(node, fn) + const children = getNodeChildren(node, fn) + if (!children) { + return checked.has(key) ? 'checked' : 'unchecked' + } + const statuses = children.map((child) => walk([child])[0]) + let status + if (statuses.every((s) => s === 'checked')) status = 'checked' + else if (statuses.some((s) => s === 'checked' || s === 'half')) status = 'half' + else status = 'unchecked' + if (status === 'checked') checked.add(key) + else checked.delete(key) + if (status === 'half') half.add(key) + else half.delete(key) + return status + }) + } + walk(Array.isArray(treeData) ? treeData : []) + return { checked, half } +} + +// 过滤:返回命中节点 key 集合,以及需要展开的祖先路径(用于高亮 + 自动展开)。 +// 大小写不敏感匹配 title。 +export function filterTree(treeData, keyword, fn) { + const matched = new Set() + const expand = new Set() + const kw = (keyword || '').trim().toLowerCase() + if (!kw) return { matched, expand } + const walk = (list, ancestors) => { + list.forEach((node) => { + const key = getNodeKey(node, fn) + const title = String(getNodeTitle(node, fn) ?? '') + if (title.toLowerCase().includes(kw)) { + matched.add(key) + ancestors.forEach((a) => expand.add(a)) + } + const children = getNodeChildren(node, fn) + if (children) walk(children, [...ancestors, key]) + }) + } + walk(Array.isArray(treeData) ? treeData : [], []) + return { matched, expand } +} + +// 拖拽落点:依据指针相对节点行的纵向位置,判定为前/中/后插入。 +export function calcDropPosition(clientY, rect) { + const offset = clientY - rect.top + if (offset < rect.height * 0.25) return 'before' + if (offset > rect.height * 0.75) return 'after' + return 'inside' +} diff --git a/code/fms/fms-vue/src/components/ui/utils/drag.js b/code/fms/fms-vue/src/components/ui/utils/drag.js new file mode 100644 index 00000000..c4d01dcd --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/utils/drag.js @@ -0,0 +1,201 @@ +/** + * 弹层拖拽 / 缩放辅助(基于 Pointer Events,跨鼠标/触屏统一)。 + * + * - bindDrag:让面板跟随手柄移动(如 Modal 标题栏),拖拽期间抑制文本选中 + * - bindResize:拖动手柄调整面板宽高(如右下角 / 边缘缩放手柄),带最小/最大尺寸约束 + * + * 均为纯 DOM 辅助:接收元素参数,不依赖组件实例。 + * 拖拽 / 缩放通过修改面板的 transform / inline size 完成,不改变文档流布局。 + */ + +function releaseUserSelect() { + document.body.style.userSelect = '' + document.body.style.webkitUserSelect = '' +} + +/** + * 移动拖拽。 + * + * @param {HTMLElement} panel 被移动的面板元素 + * @param {HTMLElement} handle 拖拽手柄元素(如标题栏 header) + * @param {Object} [options] + * @param {boolean} [options.constrain] 是否约束在视口内,默认 true + * @param {number} [options.minVisible] 面板至少保留在视口内的像素数,默认 40 + * @returns {{ reset: () => void, destroy: () => void }} + */ +export function bindDrag(panel, handle, options = {}) { + const { constrain = true, minVisible = 40 } = options + if (!panel || !handle) return { reset: () => {}, destroy: () => {} } + + // 面板当前累计偏移 + let offsetX = 0 + let offsetY = 0 + // 本次拖拽按下时的指针位置 + let startX = 0 + let startY = 0 + // 本次拖拽开始时的累计偏移(用于计算增量,避免重复累加导致乱跑) + let startOffsetX = 0 + let startOffsetY = 0 + // 本次拖拽开始时的面板 rect(用于约束计算) + let base = null + let dragging = false + + function onPointerDown(event) { + if (event.button !== 0) return + // 手柄内的交互元素(按钮/链接/表单控件)不触发拖拽 + if (event.target.closest('button, a, input, textarea, select, [data-no-drag]')) return + + dragging = true + startX = event.clientX + startY = event.clientY + startOffsetX = offsetX + startOffsetY = offsetY + const rect = panel.getBoundingClientRect() + base = { left: rect.left, top: rect.top, width: rect.width, height: rect.height } + handle.setPointerCapture(event.pointerId) + event.preventDefault() + document.body.style.userSelect = 'none' + document.body.style.webkitUserSelect = 'none' + } + + function onPointerMove(event) { + if (!dragging) return + // 总偏移 = 本次拖拽起点偏移 + 指针相对按下位置的增量 + let x = startOffsetX + (event.clientX - startX) + let y = startOffsetY + (event.clientY - startY) + if (constrain) { + // 面板最终位置 = 拖动起点 rect + 累计偏移,限制其至少保留 minVisible 在视口内 + const minLeft = minVisible - base.left + const maxLeft = window.innerWidth - base.width - minVisible - base.left + const minTop = minVisible - base.top + const maxTop = window.innerHeight - base.height - minVisible - base.top + // 面板大于视口时可自由移动该轴,不做反向约束 + if (minLeft <= maxLeft) x = Math.min(Math.max(x, minLeft), maxLeft) + if (minTop <= maxTop) y = Math.min(Math.max(y, minTop), maxTop) + } + offsetX = x + offsetY = y + panel.style.transform = `translate3d(${x}px, ${y}px, 0)` + } + + function onPointerEnd() { + if (!dragging) return + dragging = false + releaseUserSelect() + } + + handle.addEventListener('pointerdown', onPointerDown) + handle.addEventListener('pointermove', onPointerMove) + handle.addEventListener('pointerup', onPointerEnd) + handle.addEventListener('pointercancel', onPointerEnd) + + return { + /** 复位面板到初始位置并结束进行中的拖拽 */ + reset() { + offsetX = 0 + offsetY = 0 + dragging = false + panel.style.transform = '' + releaseUserSelect() + }, + /** 解绑全部监听,面板销毁前调用 */ + destroy() { + dragging = false + releaseUserSelect() + handle.removeEventListener('pointerdown', onPointerDown) + handle.removeEventListener('pointermove', onPointerMove) + handle.removeEventListener('pointerup', onPointerEnd) + handle.removeEventListener('pointercancel', onPointerEnd) + }, + } +} + +/** + * 尺寸缩放拖拽。 + * + * 手柄方向用字母组合表示:e 右 / w 左 / s 下 / n 上,如 'se'(右下角)、'e'(右侧边缘)。 + * 拖动时按方向增减面板宽高,结果通过 onResize 回调交给调用方应用。 + * + * @param {HTMLElement} panel 被缩放的面板元素 + * @param {HTMLElement} handle 缩放手柄元素 + * @param {Object} [options] + * @param {string} [options.direction] 拖拽方向,默认 'se' + * @param {number} [options.minWidth] 最小宽度,默认 200 + * @param {number} [options.minHeight] 最小高度,默认 120 + * @param {number} [options.maxWidth] 最大宽度,默认视口宽度 + * @param {number} [options.maxHeight] 最大高度,默认视口高度 + * @param {(size: { width: number, height: number }) => void} [options.onResize] 尺寸变化回调 + * @returns {{ destroy: () => void }} + */ +export function bindResize(panel, handle, options = {}) { + const { + direction = 'se', + minWidth = 200, + minHeight = 120, + maxWidth = window.innerWidth, + maxHeight = window.innerHeight, + onResize = () => {}, + } = options + if (!panel || !handle) return { destroy: () => {} } + + // 本次拖拽按下时的指针位置与面板起始尺寸 + let startX = 0 + let startY = 0 + let base = null + let dragging = false + + function onPointerDown(event) { + if (event.button !== 0) return + dragging = true + startX = event.clientX + startY = event.clientY + const rect = panel.getBoundingClientRect() + base = { width: rect.width, height: rect.height } + // 拖拽中标记:供样式在拖拽期间保持手柄高亮 + handle.classList.add('is-resizing') + handle.setPointerCapture(event.pointerId) + event.preventDefault() + document.body.style.userSelect = 'none' + document.body.style.webkitUserSelect = 'none' + } + + function onPointerMove(event) { + if (!dragging) return + const dx = event.clientX - startX + const dy = event.clientY - startY + let width = base.width + let height = base.height + if (direction.includes('e')) width = base.width + dx + if (direction.includes('w')) width = base.width - dx + if (direction.includes('s')) height = base.height + dy + if (direction.includes('n')) height = base.height - dy + width = Math.min(Math.max(width, minWidth), maxWidth) + height = Math.min(Math.max(height, minHeight), maxHeight) + onResize({ width: Math.round(width), height: Math.round(height) }) + } + + function onPointerEnd() { + if (!dragging) return + dragging = false + handle.classList.remove('is-resizing') + releaseUserSelect() + } + + handle.addEventListener('pointerdown', onPointerDown) + handle.addEventListener('pointermove', onPointerMove) + handle.addEventListener('pointerup', onPointerEnd) + handle.addEventListener('pointercancel', onPointerEnd) + + return { + /** 解绑全部监听,面板销毁前调用 */ + destroy() { + dragging = false + handle.classList.remove('is-resizing') + releaseUserSelect() + handle.removeEventListener('pointerdown', onPointerDown) + handle.removeEventListener('pointermove', onPointerMove) + handle.removeEventListener('pointerup', onPointerEnd) + handle.removeEventListener('pointercancel', onPointerEnd) + }, + } +} diff --git a/code/fms/fms-vue/src/components/ui/utils/focus.js b/code/fms/fms-vue/src/components/ui/utils/focus.js new file mode 100644 index 00000000..2b5f8a77 --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/utils/focus.js @@ -0,0 +1,62 @@ +/** + * 弹窗焦点管理辅助函数。 + * 用于 Modal / Drawer 等对话框:打开时聚焦容器、Tab 键循环、关闭后还原焦点。 + * 纯 DOM 辅助函数,不读取组件状态。 + */ + +// 可聚焦元素选择器:排除 disabled 与 tabindex=-1(隐藏元素靠 getClientRects 过滤) +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +/** + * 获取容器内当前可聚焦的元素(可见且未禁用)。 + * @param {HTMLElement} container + * @returns {HTMLElement[]} + */ +export function getFocusable(container) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.getClientRects().length > 0, + ) +} + +/** + * 聚焦容器内第一个可聚焦元素;没有时聚焦容器本身。 + * @param {HTMLElement} container + */ +export function focusFirst(container) { + const focusable = getFocusable(container) + if (focusable.length > 0) focusable[0].focus() + else container.focus() +} + +/** + * Tab 焦点陷阱:焦点在容器首尾或容器外时循环回弹。 + * 在容器自身的 keydown 事件上调用。 + * @param {KeyboardEvent} event + * @param {HTMLElement} container + */ +export function trapFocus(event, container) { + if (event.key !== 'Tab') return + const focusable = getFocusable(container) + if (focusable.length === 0) { + event.preventDefault() + container.focus() + return + } + const first = focusable[0] + const last = focusable[focusable.length - 1] + const active = document.activeElement + if (event.shiftKey && (active === first || !container.contains(active))) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && (active === last || !container.contains(active))) { + event.preventDefault() + first.focus() + } +} diff --git a/code/fms/fms-vue/src/components/ui/utils/position.js b/code/fms/fms-vue/src/components/ui/utils/position.js new file mode 100644 index 00000000..025f81bd --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/utils/position.js @@ -0,0 +1,98 @@ +/** + * 浮层定位纯函数。 + * 输入触发器与浮层尺寸、期望方位、偏移量与视口,返回无碰撞的落点坐标。 + * 纯函数、无副作用,便于单测;后续若替换为第三方定位库,只需替换此文件。 + */ + +// 可选方位:4 主方向 × 3 对齐 +export const PLACEMENTS = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', +] + +// 视口安全边距(px) +const MARGIN = 8 + +// 主方向相反表(用于 flip) +const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } + +function parsePlacement(placement) { + const [base, alignment = 'center'] = placement.split('-') + return { base, alignment } +} + +// 按 base + alignment 计算初始坐标 +function place(base, alignment, rect, size, offset) { + let x = 0 + let y = 0 + if (base === 'top' || base === 'bottom') { + x = align(alignment, rect.left, rect.width, size.width) + y = base === 'top' ? rect.top - size.height - offset : rect.bottom + offset + } else { + y = align(alignment, rect.top, rect.height, size.height) + x = base === 'left' ? rect.left - size.width - offset : rect.right + offset + } + return { x, y } +} + +function align(alignment, start, size, floatSize) { + if (alignment === 'start') return start + if (alignment === 'end') return start + size - floatSize + return start + (size - floatSize) / 2 // center +} + +function overflows(x, y, size, viewport) { + return ( + x < MARGIN || + y < MARGIN || + x + size.width > viewport.width - MARGIN || + y + size.height > viewport.height - MARGIN + ) +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +/** + * @param {object} options + * @param {string} options.placement 期望方位 + * @param {DOMRect} options.triggerRect 触发器位置 + * @param {{width:number,height:number}} options.floatSize 浮层尺寸 + * @param {number} [options.offset=0] 与触发器间距 + * @param {{width:number,height:number}} options.viewport 视口尺寸 + * @returns {{x:number,y:number,placement:string}} 落点坐标与实际方位 + */ +export function computePosition({ placement, triggerRect, floatSize, offset = 0, viewport }) { + const { base, alignment } = parsePlacement(placement) + let { x, y } = place(base, alignment, triggerRect, floatSize, offset) + + // 主轴溢出则尝试翻转,翻转后仍放不下再保留原方向交给 shift 兜底 + let resolvedBase = base + if (overflows(x, y, floatSize, viewport)) { + const flipped = OPPOSITE[base] + const flippedPos = place(flipped, alignment, triggerRect, floatSize, offset) + if (!overflows(flippedPos.x, flippedPos.y, floatSize, viewport)) { + resolvedBase = flipped + x = flippedPos.x + y = flippedPos.y + } + } + + // shift:约束回视口内(含浮层比视口大的极端情况) + x = clamp(x, MARGIN, viewport.width - floatSize.width - MARGIN) + y = clamp(y, MARGIN, viewport.height - floatSize.height - MARGIN) + + const resolvedPlacement = alignment === 'center' ? resolvedBase : `${resolvedBase}-${alignment}` + return { x, y, placement: resolvedPlacement } +} diff --git a/code/fms/fms-vue/src/components/ui/utils/scroll.js b/code/fms/fms-vue/src/components/ui/utils/scroll.js new file mode 100644 index 00000000..93ecc95b --- /dev/null +++ b/code/fms/fms-vue/src/components/ui/utils/scroll.js @@ -0,0 +1,41 @@ +/** + * body 滚动锁。 + * 弹窗(Modal / Drawer)打开时锁定页面滚动,关闭时恢复。 + * 支持多个弹窗同时打开:引用计数,最后一个关闭才真正恢复滚动。 + * 模块级计数属于全局副作用,所有弹窗实例共享。 + */ + +let lockCount = 0 +let restoreStyle = null + +/** + * 锁定 body 滚动。首次锁定时记录原样式并补偿滚动条宽度, + * 避免锁定瞬间内容横向跳动。 + */ +export function lockScroll() { + if (lockCount === 0) { + const body = document.body + restoreStyle = { + overflow: body.style.overflow, + paddingRight: body.style.paddingRight, + } + // 锁定后滚动条消失,用 padding 补偿原滚动条宽度防止内容位移 + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px` + body.style.overflow = 'hidden' + } + lockCount++ +} + +/** + * 解锁 body 滚动。所有弹窗关闭后恢复原始样式。 + */ +export function unlockScroll() { + if (lockCount === 0) return + lockCount-- + if (lockCount === 0 && restoreStyle) { + document.body.style.overflow = restoreStyle.overflow + document.body.style.paddingRight = restoreStyle.paddingRight + restoreStyle = null + } +} diff --git a/code/fms/fms-vue/src/layouts/DefaultLayout.vue b/code/fms/fms-vue/src/layouts/DefaultLayout.vue new file mode 100644 index 00000000..be17cc2e --- /dev/null +++ b/code/fms/fms-vue/src/layouts/DefaultLayout.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue new file mode 100644 index 00000000..7fef49c0 --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/AppTabs.vue b/code/fms/fms-vue/src/layouts/components/AppTabs.vue new file mode 100644 index 00000000..1abc82bf --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/AppTabs.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/AppTopbar.vue b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue new file mode 100644 index 00000000..61101ecf --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/NavMain.vue b/code/fms/fms-vue/src/layouts/components/NavMain.vue new file mode 100644 index 00000000..8b4e401e --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/NavMain.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue b/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue new file mode 100644 index 00000000..868da306 --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/NavMenuItem.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/code/fms/fms-vue/src/layouts/components/index.scss b/code/fms/fms-vue/src/layouts/components/index.scss new file mode 100644 index 00000000..868c8d8c --- /dev/null +++ b/code/fms/fms-vue/src/layouts/components/index.scss @@ -0,0 +1,678 @@ +/* ============================================================ + * 布局组件样式(非 scoped,类名带 fms- 前缀避免冲突) + * 侧栏 / 团队切换 / 菜单 / 顶栏 / 页签 + * 基于 --fms-* token,无响应式断点 + * 对齐 fms-vue-old2(shadcn sidebar)的视觉规格: + * 侧栏浅灰背景、菜单项 h-8 rounded-md p-2、hover 浅灰、 + * 激活项白底 + shadow-sm、子菜单 mx-3.5 border-l px-2.5 + * ============================================================ */ + +/* ---- 侧栏容器 ---- */ +.fms-sidebar-body { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; +} + +/* 团队切换(对齐 SidebarHeader p-2 + TeamSwitcher h-12) */ +.fms-team { + flex-shrink: 0; + padding: 8px; +} + +.fms-team-trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 48px; + padding: 8px; + border: none; + border-radius: var(--fms-control-radius); + background: transparent; + color: var(--fms-text); + font-family: inherit; + font-size: var(--fms-font-size); + text-align: left; + cursor: pointer; + transition: background 150ms ease; + + &:hover { + background: var(--fms-secondary); + } + + &:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; + } +} + +.fms-team-logo { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--fms-primary); + color: var(--fms-primary-text); + + svg { + width: 18px; + height: 18px; + } +} + +.fms-team-info { + display: flex; + flex: 1; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +.fms-team-name { + max-width: 100%; + overflow: hidden; + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-team-plan { + font-size: 12px; + line-height: 16px; + color: var(--fms-text-secondary); +} + +.fms-team-caret { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--fms-text-secondary); +} + +/* 团队下拉菜单(#menu 插槽自定义内容) */ +.fms-team-menu { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 180px; +} + +.fms-team-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border: none; + border-radius: var(--fms-dropdown-item-radius); + background: transparent; + color: var(--fms-text); + font-family: inherit; + font-size: var(--fms-font-size); + text-align: left; + cursor: pointer; + transition: background 150ms ease; + + &:hover { + background: var(--fms-secondary-hover); + } +} + +.fms-team-menu-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--fms-secondary-hover); + color: var(--fms-text); + + svg { + width: 14px; + height: 14px; + } +} + +.fms-team-menu-info { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.fms-team-menu-name { + font-size: 14px; + font-weight: 500; +} + +.fms-team-menu-plan { + font-size: 12px; + color: var(--fms-text-secondary); +} + +/* 菜单滚动区(对齐 SidebarContent + SidebarGroup p-2) */ +.fms-nav-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 8px 8px; +} + +.fms-nav-scroll::-webkit-scrollbar { + width: 6px; +} + +.fms-nav-scroll::-webkit-scrollbar-thumb { + border-radius: 3px; + background: var(--fms-border); +} + +/* ---- 菜单 ----(对齐 SidebarMenu gap-1) */ +.fms-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* 菜单项基础布局:叶子链接与目录容器共用(hover 背景单独定义在 link/btn 上) */ +.fms-nav-item { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 8px; + border-radius: 6px; + color: var(--fms-text); + font-size: var(--fms-font-size); + line-height: 20px; + text-decoration: none; + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease, + box-shadow 150ms ease; +} + +/* 叶子链接 hover:仅自身高亮 */ +.fms-nav-link:hover { + background: var(--fms-secondary); + color: var(--fms-text); +} + +/* 目录项容器:垂直堆叠按钮与子菜单。 + 自身不渲染背景 / hover / 内边距,避免鼠标移入子菜单时整个父级连带高亮 */ +.fms-nav-dir { + flex-direction: column; + align-items: stretch; + padding: 0; + cursor: default; +} + +/* 目录项按钮化 */ +.fms-nav-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 0 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--fms-text); + font-family: inherit; + font-size: var(--fms-font-size); + line-height: 20px; + text-align: left; + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; + + &:hover { + background: var(--fms-secondary); + color: var(--fms-text); + } + + &:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: -2px; + } +} + +.fms-nav-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 20px; + color: inherit; + + svg { + width: 18px; + height: 18px; + stroke-width: 2; + } +} + +.fms-nav-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-nav-caret { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--fms-text-secondary); + transition: transform 150ms ease; +} + +/* 折叠态下无图标叶子项:显示标题首字符 */ +.fms-nav-fallback { + font-size: 14px; + font-weight: 500; + color: var(--fms-text); +} + +/* 目录展开:仅箭头旋转 90°(对齐 old2 Collapsible) */ +.fms-nav-dir.is-open > .fms-nav-btn .fms-nav-caret { + transform: rotate(90deg); +} + +/* 子菜单:mx-3.5 border-l px-2.5(对齐 SidebarMenuSub) */ +.fms-nav-sub { + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 14px; + padding-left: 10px; + border-left: 1px solid var(--fms-border); +} + +/* 子菜单项:h-7 略矮(对齐 SidebarMenuSubButton) */ +.fms-nav-sub .fms-nav-item, +.fms-nav-sub .fms-nav-btn { + min-height: 28px; +} + +/* 激活高亮:白底 + shadow-sm + 深色文字(对齐 data-[active=true]) */ +.fms-nav-link.is-active { + background: var(--fms-card); + color: var(--fms-text); + font-weight: 500; + box-shadow: var(--fms-shadow); +} + +/* ---- 折叠态:仅图标,居中展示(对齐 size-8 icon 模式) ---- */ +.fms-sidebar-body.is-collapsed { + .fms-team { + padding: 8px 10px; + } + + .fms-team-trigger { + justify-content: center; + padding: 8px 0; + } + + .fms-nav-scroll { + padding: 0 8px 8px; + } + + .fms-nav-item, + .fms-nav-btn { + justify-content: center; + padding: 0; + } +} + +/* ---- 顶栏 ---- */ +.fms-topbar { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 8px; + height: 48px; + padding: 0 12px; + background: var(--fms-card); + border-bottom: 1px solid var(--fms-border); +} + +.fms-topbar-left, +.fms-topbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.fms-topbar-left { + flex: 1; + min-width: 0; +} + +/* 顶栏图标按钮:原生 button(与 ThemeToggle 同构,避免 Button scoped 规则覆盖图标尺寸) */ +.fms-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: var(--fms-control-radius); + background: transparent; + color: var(--fms-text); + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; + + svg { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--fms-secondary); + color: var(--fms-text); + } + + &:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; + } +} + +.fms-separator { + flex-shrink: 0; + width: 1px; + height: 16px; + margin: 0 6px; + background: var(--fms-border); +} + +/* 面包屑 */ +.fms-breadcrumb { + display: flex; + align-items: center; + min-width: 0; + font-size: 14px; +} + +.fms-breadcrumb-item { + color: var(--fms-text-secondary); + white-space: nowrap; +} + +.fms-breadcrumb-item.is-current { + color: var(--fms-text); + font-weight: 500; +} + +.fms-breadcrumb-sep { + margin: 0 8px; + color: var(--fms-border); +} + +/* 主题色板浮层 */ +.fms-topbar-scheme { + padding: 4px 8px 8px; +} + +/* 用户触发按钮 */ +.fms-user-trigger { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 8px; + border: none; + border-radius: var(--fms-control-radius); + background: transparent; + color: var(--fms-text); + font-family: inherit; + font-size: var(--fms-font-size); + cursor: pointer; + transition: background 150ms ease; + + &:hover { + background: var(--fms-secondary-hover); + } + + &:focus-visible { + outline: 2px solid var(--fms-primary); + outline-offset: 2px; + } +} + +/* 首字母头像 */ +.fms-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 8px; + background: color-mix(in srgb, var(--fms-primary) 12%, transparent); + color: var(--fms-primary); + font-size: 13px; + font-weight: 600; + user-select: none; +} + +.fms-avatar-lg { + width: 32px; + height: 32px; + border-radius: 10px; + font-size: 14px; +} + +.fms-user-name { + max-width: 120px; + overflow: hidden; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-user-caret { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--fms-text-secondary); +} + +/* 用户下拉菜单(#menu 插槽自定义内容) */ +.fms-user-menu { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 200px; +} + +.fms-user-menu-header { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; +} + +.fms-user-menu-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +.fms-user-menu-name { + max-width: 160px; + overflow: hidden; + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-user-menu-org { + max-width: 160px; + overflow: hidden; + font-size: 12px; + color: var(--fms-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-user-menu-divider { + height: 1px; + margin: 4px 0; + background: var(--fms-border); +} + +.fms-user-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: none; + border-radius: var(--fms-dropdown-item-radius); + background: transparent; + color: var(--fms-text); + font-family: inherit; + font-size: var(--fms-font-size); + text-align: left; + cursor: pointer; + transition: background 150ms ease; + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--fms-secondary-hover); + } +} + +.fms-user-menu-danger { + color: var(--fms-danger); + + &:hover { + background: color-mix(in srgb, var(--fms-danger) 10%, transparent); + } +} + +/* ---- 页签栏 ---- */ +.fms-tabs { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 4px; + height: 40px; + padding: 0 12px; + overflow: hidden; + background: var(--fms-card); + border-bottom: 1px solid var(--fms-border); +} + +.fms-tabs-arrow { + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + + svg { + width: 16px; + height: 16px; + } +} + +.fms-tabs-track { + display: flex; + flex: 1; + align-items: center; + gap: 6px; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.fms-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + height: 28px; + padding: 0 10px; + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + background: var(--fms-card); + color: var(--fms-text-secondary); + font-family: inherit; + font-size: 12px; + font-weight: 500; + line-height: 20px; + cursor: pointer; + transition: + background 150ms ease, + border-color 150ms ease, + color 150ms ease; + + &:hover { + border-color: var(--fms-text-secondary); + color: var(--fms-text); + } + + &.is-active { + background: var(--fms-card); + border-color: var(--fms-primary); + color: var(--fms-primary); + } +} + +.fms-tab-label { + max-width: 144px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fms-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: -4px; + border-radius: 4px; + color: var(--fms-text-secondary); + transition: + background 150ms ease, + color 150ms ease; + + svg { + width: 12px; + height: 12px; + } + + &:hover { + background: color-mix(in srgb, var(--fms-text-secondary) 20%, transparent); + color: var(--fms-text); + } +} diff --git a/code/fms/fms-vue/src/layouts/index.scss b/code/fms/fms-vue/src/layouts/index.scss new file mode 100644 index 00000000..7daadcc5 --- /dev/null +++ b/code/fms/fms-vue/src/layouts/index.scss @@ -0,0 +1,98 @@ +/* ============================================================ + * 布局壳全局样式(非 scoped): + * 布局骨架 + 页面过渡动画 + * 基于 --fms-* token,无响应式断点 + * 侧栏 / 主区由 Splitter 组件布局,拖拽条为 bar-on-hover + * ============================================================ */ + +/* ---- 布局骨架 ---- */ +.fms-layout { + display: flex; + height: 100vh; + min-width: 0; + overflow: hidden; + background: var(--fms-background); +} + +/* Splitter 容器:撑满布局高度 */ +.fms-layout-splitter { + flex: 1; + min-width: 0; +} + +/* 折叠态:隐藏分隔条(此时侧栏固定 64px 且不可调整) */ +.fms-layout.is-collapsed .fms-layout-splitter .fms-splitter-bar { + display: none; +} + +/* 侧栏面板宽度过渡:折叠/展开切换平滑;拖拽时禁用保证跟手 */ +.fms-layout-splitter .fms-splitter-panel { + transition: flex-basis 180ms ease; +} + +.fms-layout-splitter.is-dragging .fms-splitter-panel { + transition: none; +} + +/* 主区面板:四周留 8px 间距。 + * 注意不能只留左 0——splitter 面板默认 overflow: auto 会把主区左侧 + * 溢出的阴影裁剪掉,四周留距才能让阴影完整可见(old2 中左侧被 sidebar 遮挡所以没暴露此问题) */ +.fms-main-panel { + padding: 8px; +} + +/* 主区:白底卡片,圆角 + 阴影,撑满面板内容区 */ +.fms-main { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--fms-card); + border-radius: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* 深色下加重投影 */ +.dark .fms-main { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +.fms-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 16px 16px; +} + +/* ---- 页面过渡 ---- */ +.page-enter-active { + transition: + opacity 160ms ease-out, + transform 160ms ease-out; +} + +.page-leave-active { + transition: opacity 80ms ease-in; +} + +.page-enter-from { + opacity: 0; + transform: translateY(4px); +} + +.page-leave-to { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .fms-layout-splitter .fms-splitter-panel { + transition: none; + } + + .page-enter-active, + .page-leave-active { + transition: none; + } +} diff --git a/code/fms/fms-vue/src/main.js b/code/fms/fms-vue/src/main.js index e6536bc9..6a41e16a 100644 --- a/code/fms/fms-vue/src/main.js +++ b/code/fms/fms-vue/src/main.js @@ -1,11 +1,20 @@ import { createApp } from 'vue' -import '@fontsource-variable/noto-sans-sc/wght.css' +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import './styles/index.css' +import './styles/demo.css' import App from './App.vue' import router from './router' -import 'stk-table-vue/lib/style.css' -import { initTheme } from './theme/useTheme' +import { initThemeSync } from './stores/app' -initTheme() +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) -createApp(App).use(router).mount('#app') +const app = createApp(App) +app.use(pinia) +app.use(router) + +// mount 前初始化主题同步(恢复持久化状态并写入 DOM,避免首屏闪烁) +initThemeSync(pinia) + +app.mount('#app') diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index 835241dc..2bf5bf41 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -1,8 +1,80 @@ import { createRouter, createWebHistory } from 'vue-router' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import DefaultLayout from '@/layouts/DefaultLayout.vue' +import { useAuthStore } from '@/stores/auth' +import { usePermissionStore } from '@/stores/permissions' + +NProgress.configure({ showSpinner: false }) const router = createRouter({ history: createWebHistory(), - routes: [], + routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录' }, + }, + { + path: '/', + component: DefaultLayout, + redirect: '/dashboard', + meta: { requiresAuth: true }, + children: [ + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '工作台' }, + }, + // 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验 + ], + }, + { + // 组件库演练场:独立路由,不进入业务布局 + path: '/demo', + name: 'demo', + component: () => import('@/views/demo/index.vue'), + meta: { title: '组件库' }, + }, + { + path: '/:pathMatch(.*)*', + redirect: '/dashboard', + }, + ], +}) + +// 登录守卫:未登录访问受保护页跳 /login?redirect=xxx;已登录访问 /login 跳 /dashboard +// 权限守卫:路由配置 meta.moduleId 时校验访问权限,无权限跳 /dashboard +router.beforeEach(async (to) => { + NProgress.start() + const authStore = useAuthStore() + + if (to.path === '/login') { + if (authStore.isAuthenticated) return '/dashboard' + return + } + + if (to.matched.some((r) => r.meta.requiresAuth)) { + if (!authStore.isAuthenticated) { + return { path: '/login', query: { redirect: to.fullPath } } + } + + const moduleId = to.meta.moduleId + if (moduleId) { + const permissionStore = usePermissionStore() + // 权限接口失败时静默降级:canAccess 返回 false,与"菜单退化为仅工作台"策略一致 + await permissionStore.load().catch(() => {}) + if (!permissionStore.canAccess(moduleId)) { + return '/dashboard' + } + } + } +}) + +router.afterEach(() => { + NProgress.done() }) export default router diff --git a/code/fms/fms-vue/src/services/api.js b/code/fms/fms-vue/src/services/api.js new file mode 100644 index 00000000..7841f6f7 --- /dev/null +++ b/code/fms/fms-vue/src/services/api.js @@ -0,0 +1,80 @@ +import { post } from './http' + +/** + * 机构用户登录。 + */ +export function loginApi(orgId, userId, password) { + return post('/auth/login', { orgid: orgId, userid: userId, password }) +} + +/** + * 按表或视图加载数据。 + * @param viewName 表名或视图名,必填。 + * @param searchCondition 查询条件,不包含 WHERE。 + * @param orderBy 排序字段,可带 ASC 或 DESC。 + * @param searchColumns 需要返回的字段列表,省略时返回全部字段。 + */ +export function loadDataApi(viewName, searchCondition, orderBy, searchColumns) { + return post('/data/loaddata', { + view_name: viewName, + search_condition: searchCondition, + order_by: orderBy, + search_columns: searchColumns, + }) +} + +/** + * 执行只读 SQL 查询。 + */ +export function loadDataBySqlApi(sql) { + return post('/data/loaddatabysql', { sql }) +} + +/** + * 分页加载表或视图数据。 + */ +export function pageDataApi({ + viewName, + orderBy, + searchColumns, + searchCondition, + pageNo = 1, + pageSize = 20, +}) { + return post('/data/page', { + view_name: viewName, + order_by: orderBy, + search_columns: searchColumns, + search_condition: searchCondition, + page_no: pageNo, + page_size: pageSize, + }) +} + +/** + * 在同一事务中保存一个或多个表的数据变更。 + */ +export function saveObjectApi(requests) { + return post('/data/saveobjt', requests) +} + +/** + * 获取下一个雪花 ID;传入 count 可批量获取指定数量的 ID(返回数组)。 + */ +export function nextIdApi(count = 1) { + return post('/data/nextid', { count }) +} + +/** + * 按模块规则原子获取业务编号。 + */ +export function nextCodeApi(moduleId, count = 1) { + return post('/data/nextcode', { moduleId: String(moduleId), count }) +} + +/** + * 读取表或视图的列结构。 + */ +export function describeApi(table) { + return post('/data/describe', { table }) +} diff --git a/code/fms/fms-vue/src/services/http.js b/code/fms/fms-vue/src/services/http.js new file mode 100644 index 00000000..15d62eaa --- /dev/null +++ b/code/fms/fms-vue/src/services/http.js @@ -0,0 +1,96 @@ +import axios from 'axios' +import { useAuthStore } from '@/stores/auth' + +const AUTH_ERROR_CODE = 401 +const SESSION_REPLACED_CODE = 40101 + +const http = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 15000, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +let redirectingToLogin = false + +function redirectToLogin(reason) { + const currentPath = window.location.pathname.replace(/\/+$/, '') || '/' + if (currentPath === '/login' || redirectingToLogin) return + + redirectingToLogin = true + const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}` + const query = new URLSearchParams({ redirect }) + if (reason) query.set('reason', reason) + window.location.assign(`/login?${query.toString()}`) +} + +function isAuthenticationError(code) { + return code === AUTH_ERROR_CODE || code === SESSION_REPLACED_CODE +} + +function handleUnauthorized(code) { + const authStore = useAuthStore() + authStore.clearSession() + redirectToLogin(code === SESSION_REPLACED_CODE ? 'session-replaced' : 'expired') +} + +// 请求拦截器:注入 Bearer token(调用时读取 store,保证 pinia 已激活) +http.interceptors.request.use((config) => { + const authStore = useAuthStore() + if (authStore.token) { + config.headers.Authorization = `Bearer ${authStore.token}` + } + return config +}) + +// 响应拦截器:统一按 ApiResult 结构处理 +http.interceptors.response.use( + (response) => { + const payload = response.data + + // 无 code 字段时按成功透传 + if (payload?.code === undefined) { + return { + success: true, + code: 0, + message: '', + data: payload, + } + } + + payload.success = payload.code === 0 + + if (payload.success) return response + + if (isAuthenticationError(payload.code)) handleUnauthorized(payload.code) + + const err = new Error(payload.message || '请求失败') + err.code = payload.code + err.data = payload.data + err.success = payload.success + throw err + }, + (error) => { + const status = error.response?.status + const payload = error.response?.data + + handleUnauthorized(payload?.code ?? status ?? 0) + + const requestError = new Error( + payload?.message || + (error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络异常,请检查服务是否正常'), + ) + requestError.code = payload?.code ?? status ?? -1 + requestError.data = payload?.data + requestError.success = false + throw requestError + }, +) + +export function post(path, data, config) { + return http.post(path, data, config).then((r) => r.data) +} + +export default http diff --git a/code/fms/fms-vue/src/stores/app.js b/code/fms/fms-vue/src/stores/app.js new file mode 100644 index 00000000..a6a8f00c --- /dev/null +++ b/code/fms/fms-vue/src/stores/app.js @@ -0,0 +1,79 @@ +import { defineStore } from 'pinia' +import { watch } from 'vue' +import { DEFAULT_PRIMARY } from '@/theme/presets' +import { applyPrimaryColor, applyDarkMode } from '@/theme/index' + +export const useAppStore = defineStore('app', { + state: () => ({ + darkMode: false, + primaryColor: DEFAULT_PRIMARY, + /** 侧边栏是否折叠为图标栏 */ + sidebarCollapsed: false, + /** 界面语言(占位:项目暂无 i18n 文案,仅保留切换状态) */ + locale: 'zh-CN', + /** 已访问页签列表;工作台固定不可关闭 */ + visitedTabs: [{ key: '/dashboard', label: '工作台', closable: false }], + /** 路由刷新计数表:key 为路由 fullPath,值变化即强制重渲染该路由 */ + routeRefreshKeys: {}, + }), + + actions: { + setDark(value) { + this.darkMode = !!value + }, + toggleDark() { + this.darkMode = !this.darkMode + }, + setPrimary(color) { + this.primaryColor = color + }, + toggleSidebar() { + this.sidebarCollapsed = !this.sidebarCollapsed + }, + setSidebar(collapsed) { + this.sidebarCollapsed = !!collapsed + }, + toggleLocale() { + this.locale = this.locale === 'zh-CN' ? 'en-US' : 'zh-CN' + }, + addVisitedTab(tab) { + if (this.visitedTabs.some((t) => t.key === tab.key)) return + this.visitedTabs.push(tab) + }, + removeVisitedTab(key) { + const index = this.visitedTabs.findIndex((t) => t.key === key) + if (index >= 0) this.visitedTabs.splice(index, 1) + }, + getRouteRefreshKey(key) { + return this.routeRefreshKeys[key] ?? 0 + }, + refreshRoute(key) { + this.routeRefreshKeys[key] = (this.routeRefreshKeys[key] ?? 0) + 1 + }, + }, + + // 持久化状态:刷新后保持主题/侧栏/语言/页签;routeRefreshKeys 不持久化(仅本次会话有效) + persist: { + pick: ['darkMode', 'primaryColor', 'sidebarCollapsed', 'locale', 'visitedTabs'], + }, +}) + +// 在 store 定义后立即建立 watch,将状态同步到 DOM +// 需在 pinia 实例激活后才能调用,故封装成初始化函数 +export function initThemeSync(pinia) { + const store = useAppStore(pinia) + + watch( + () => store.darkMode, + (val) => applyDarkMode(val), + { immediate: true }, + ) + + watch( + () => store.primaryColor, + (val) => applyPrimaryColor(val), + { immediate: true }, + ) + + return store +} diff --git a/code/fms/fms-vue/src/stores/auth.js b/code/fms/fms-vue/src/stores/auth.js new file mode 100644 index 00000000..e716d9e1 --- /dev/null +++ b/code/fms/fms-vue/src/stores/auth.js @@ -0,0 +1,50 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +const AUTH_STORAGE_KEY = 'fms-auth' + +// 兼容旧版 sessionStorage 会话:将历史会话迁移到 localStorage +try { + const session = sessionStorage.getItem(AUTH_STORAGE_KEY) + if (session && !localStorage.getItem(AUTH_STORAGE_KEY)) { + localStorage.setItem(AUTH_STORAGE_KEY, session) + } +} catch { + // Storage can be unavailable in restricted browser contexts. +} + +export const useAuthStore = defineStore( + 'auth', + () => { + const token = ref('') + const orgId = ref('') + const user = ref(null) + const isAuthenticated = computed(() => Boolean(token.value)) + + function setSession(session, organizationId) { + token.value = session.token ?? session.access_token ?? '' + orgId.value = session.orgid ?? organizationId ?? '' + user.value = session.user ?? session.user_info ?? null + } + + function clearSession() { + token.value = '' + } + + return { + token, + orgId, + user, + isAuthenticated, + setSession, + clearSession, + } + }, + { + persist: { + key: AUTH_STORAGE_KEY, + storage: localStorage, + pick: ['token', 'orgId', 'user'], + }, + }, +) diff --git a/code/fms/fms-vue/src/stores/permissions.js b/code/fms/fms-vue/src/stores/permissions.js new file mode 100644 index 00000000..6ed5baed --- /dev/null +++ b/code/fms/fms-vue/src/stores/permissions.js @@ -0,0 +1,183 @@ +import { computed, ref, watch } from 'vue' +import { defineStore } from 'pinia' +import { loadDataApi } from '@/services/api' +import { useAuthStore } from './auth' + +/** + * 权限 store:负责拉取模块 / 用户授权数据并暴露访问与功能点判断。 + * 逻辑与 fms-vue-old2 完全一致: + * - 管理员账号(g3soft)提权,直接可见全部启用模块 + * - 普通用户按 b_user_module / b_user_power 授权 + * - 同一主体(orgId:userId)加载结果去重,并发请求复用 + */ +export const usePermissionStore = defineStore('permissions', () => { + const authStore = useAuthStore() + const loadedPrincipalKey = ref('') + const modules = ref([]) + const grantedPageIds = ref([]) + const moduleCodes = ref([]) + const powerCodes = ref([]) + let loadingPromise = null + let loadingPrincipalKey = '' + let loadVersion = 0 + + const moduleByCode = computed( + () => new Map(modules.value.map((m) => [String(m.b_code || ''), m])), + ) + const moduleById = computed(() => new Map(modules.value.map((m) => [String(m.b_id || ''), m]))) + const grantedPageSet = computed(() => new Set(grantedPageIds.value)) + const powerSet = computed(() => new Set(powerCodes.value)) + const isG3soft = computed(() => { + return ( + String(authStore.user?.account || '') + .trim() + .toLowerCase() === 'g3soft' + ) + }) + + /** + * 加载权限数据;重复调用对同一主体去重,并发调用共享同一请求。 + */ + async function load() { + const userId = String(authStore.user?.id || '') + const principalKey = `${String(authStore.orgId || '')}:${userId}` + + // 管理员提权:直接加载全部启用模块,不查授权表 + if (isG3soft.value) { + const elevatedKey = `g3soft:${principalKey}` + if (loadedPrincipalKey.value === elevatedKey) return + loadingPromise = null + loadingPrincipalKey = '' + + const requestVersion = ++loadVersion + const request = loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC') + .then((res) => { + if (requestVersion !== loadVersion) return + modules.value = res.data || [] + grantedPageIds.value = [] + moduleCodes.value = [] + powerCodes.value = [] + loadedPrincipalKey.value = elevatedKey + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + loadingPromise = request + return request + } + + if (!/^\d+$/.test(userId)) throw new Error('登录用户 ID 无效,请重新登录') + if (loadedPrincipalKey.value === principalKey) return + if (loadingPromise && loadingPrincipalKey === principalKey) return loadingPromise + + const requestVersion = ++loadVersion + loadingPrincipalKey = principalKey + const request = Promise.all([ + loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC'), + loadDataApi('b_user_module', `b_user_id = ${userId}`, 'b_id ASC'), + loadDataApi('b_user_power', `b_user_id = ${userId}`, 'b_id ASC'), + loadDataApi('s_module_power', 'b_canuse = 1', 'b_id ASC'), + ]) + .then(([moduleResponse, userModules, userPowers, powers]) => { + if (requestVersion !== loadVersion) return + const activeModules = moduleResponse.data || [] + const mById = new Map(activeModules.map((m) => [String(m.b_id), m])) + const pById = new Map(powers.data.map((p) => [String(p.b_id), p])) + const pageIds = userModules.data + .map((item) => String(item.b_module_id || '')) + .filter(Boolean) + modules.value = activeModules + grantedPageIds.value = pageIds + moduleCodes.value = userModules.data + .map((item) => mById.get(String(item.b_module_id))?.b_code) + .filter(Boolean) + powerCodes.value = userPowers.data + .map((item) => pById.get(String(item.b_power_id))) + .filter((p) => Boolean(p)) + .map((power) => `${String(power.b_module_id)}.${power.b_code}`) + loadedPrincipalKey.value = principalKey + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + loadingPromise = request + return request + } + + /** + * 由模块编码向上查找其所属 page 类型模块的 ID。 + */ + function resolvePageId(code) { + let module = moduleByCode.value.get(String(code || '')) + const visited = new Set() + while (module) { + const moduleId = String(module.b_id || '') + if (!moduleId || visited.has(moduleId)) return '' + visited.add(moduleId) + if (module.b_module_type === 'page') return moduleId + const parentId = String(module.b_parent_id || '') + module = parentId ? moduleById.value.get(parentId) : undefined + } + return '' + } + + /** + * 判断当前用户是否有某模块的访问权限(管理员恒为 true)。 + */ + function canAccess(moduleCode) { + if (isG3soft.value || !moduleCode) return true + const module = moduleByCode.value.get(String(moduleCode)) + if (!module) return false + const moduleId = String(module.b_id || '') + const pageId = resolvePageId(moduleCode) + return grantedPageSet.value.has(moduleId) || (!!pageId && grantedPageSet.value.has(pageId)) + } + + /** + * 判断当前用户是否有某模块的功能点权限。 + */ + function canPower(scopeCode, powerCode) { + if (isG3soft.value) return true + const module = moduleByCode.value.get(String(scopeCode || '')) + if (!module || !canAccess(scopeCode)) return false + const moduleId = String(module.b_id || '') + const pageId = resolvePageId(scopeCode || '') + return ( + powerSet.value.has(`${moduleId}.${powerCode}`) || + (!!pageId && powerSet.value.has(`${pageId}.${powerCode}`)) + ) + } + + function clear() { + loadVersion++ + loadingPromise = null + loadingPrincipalKey = '' + loadedPrincipalKey.value = '' + modules.value = [] + grantedPageIds.value = [] + moduleCodes.value = [] + powerCodes.value = [] + } + + // 登录主体变化(退出/切换账号)时清空权限缓存 + watch( + () => [authStore.orgId, authStore.user?.id, authStore.user?.account, authStore.token], + clear, + ) + + return { + load, + canAccess, + canPower, + clear, + modules, + grantedPageIds, + moduleCodes, + powerCodes, + isG3soft, + } +}) diff --git a/code/fms/fms-vue/src/styles/demo.css b/code/fms/fms-vue/src/styles/demo.css new file mode 100644 index 00000000..38ccc8dd --- /dev/null +++ b/code/fms/fms-vue/src/styles/demo.css @@ -0,0 +1,80 @@ +/* 演示页面通用样式:供 demo/*.vue 各组件演示与 App.vue 共用 */ + +.showcase-section { + padding: 8px 0 48px; +} + +.section-heading { + margin-bottom: 28px; +} + +.section-heading h2 { + margin: 4px 0 8px; + font-size: 18px; + font-weight: 500; +} + +.section-heading p { + margin: 0; + color: var(--fms-text-secondary); +} + +.demo-label { + margin: 24px 0 12px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fms-text-secondary); +} + +.demo-panel { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 12px; + padding: 24px; + background: var(--fms-card); + border: 1px solid var(--fms-border); + border-radius: var(--fms-control-radius); + box-shadow: var(--fms-shadow); +} + +.demo-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.demo-panel-column { + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +/* 十二方位演示网格(popover / tooltip 共用) */ +.popover-placement-grid { + display: grid; + grid-template-columns: repeat(4, auto); + justify-content: start; + gap: 12px; +} + +/* 浮层内容通用排版(popover / tooltip / tabs 内容区等) */ +.popover-demo-title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; +} + +.popover-demo-text { + margin: 0; + color: var(--fms-text-secondary); +} + +.popover-demo-form { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; +} diff --git a/code/fms/fms-vue/src/styles/index.css b/code/fms/fms-vue/src/styles/index.css index fcb98118..f5e6d22e 100644 --- a/code/fms/fms-vue/src/styles/index.css +++ b/code/fms/fms-vue/src/styles/index.css @@ -1,86 +1,16 @@ -:root { - --font-sans: 'Noto Sans SC Variable', 'PingFang SC', 'Microsoft YaHei', sans-serif; +@import '../theme/tokens.css'; +@import '../components/ui/grid/index.scss'; +@import '@fontsource/noto-sans-sc/400.css'; +@import '@fontsource/noto-sans-sc/500.css'; +@import '@fontsource/noto-sans-sc/600.css'; - --background: #ffffff; - --foreground: #09090b; - --card: #ffffff; - --card-foreground: #09090b; - /* 主色系列(--primary / --primary-hover / --primary-active / --primary-foreground / --primary-border / --ring) - 由 theme/useTheme.js 在运行时根据主题色计算并写入 :root.style,此处不重复定义以避免双真相源。 */ - --secondary: #f4f4f5; - --secondary-foreground: #18181b; - --muted-foreground: #71717a; - --accent: #f4f4f5; - --accent-foreground: #18181b; - --destructive: #ef4444; - --destructive-foreground: #fafafa; - --border: #e4e4e7; - --input: #e4e4e7; - --radius: 0.625rem; - - font-family: var(--font-sans); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - line-height: 1.5; - color: var(--foreground); - background: var(--background); -} - -:root[data-theme='dark'] { - --background: #09090b; - --foreground: #fafafa; - --card: #18181b; - --card-foreground: #fafafa; - --secondary: #27272a; - --secondary-foreground: #fafafa; - --muted-foreground: #a1a1aa; - --accent: #27272a; - --accent-foreground: #fafafa; - --destructive: #7f1d1d; - --destructive-foreground: #fafafa; - --border: #ffffff1a; - --input: #ffffff26; +*, +*::before, +*::after { + box-sizing: border-box; } body { margin: 0; - font-family: inherit; - min-width: 320px; - color: var(--foreground); - background: var(--background); - transition: - color 160ms ease, - background-color 160ms ease; -} - -button, -input, -select, -textarea { - font: inherit; -} - -button { - cursor: pointer; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -h1, -h2 { - margin: 0; -} - -h1 { - font-size: clamp(1.5rem, 4vw, 2rem); -} - -h2 { - font-size: 1.25rem; + font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; } diff --git a/code/fms/fms-vue/src/theme/color.js b/code/fms/fms-vue/src/theme/color.js deleted file mode 100644 index 59867965..00000000 --- a/code/fms/fms-vue/src/theme/color.js +++ /dev/null @@ -1,196 +0,0 @@ -// 约定:对外一律 hex('#rrggbb'),内部算法用 RGB 数组 [r, g, b]。 - -// ---------- 配置 ---------- -export const STORAGE_KEYS = { - mode: 'fms-theme', - color: 'fms-theme-color', -} - -export const DEFAULT_MODE = 'light' -export const DEFAULT_COLOR = '#18181b' - -export const PRESETS = [ - { name: 'Shadcn 中性', value: '#18181b' }, - { name: '蓝色', value: '#2563eb' }, - { name: '靛蓝', value: '#4f46e5' }, - { name: '紫色', value: '#7c3aed' }, - { name: '青色', value: '#0d9488' }, - { name: '绿色', value: '#16a34a' }, - { name: '橙色', value: '#ea580c' }, - { name: '红色', value: '#dc2626' }, - { name: '粉色', value: '#db2777' }, -] - -export const ALGORITHM = { - minContrast: 3, // 主色与背景最小对比度 - hoverDelta: 0.06, // hover 明度偏移 - activeDelta: 0.14, // active 明度偏移 - borderMixRatio: 0.55, // 边框与背景混合比例 -} - -// 背景参照色:用于主色对比度兜底(浅色=纯白、深色=zinc-900,与原始实现保持一致)。 -export const SURFACES = { - light: '#ffffff', - dark: '#18181b', -} - -// 主色上文字的前景候选(白 / 近黑),取对比度更大者。 -export const FOREGROUND_CANDIDATES = ['#ffffff', '#18181b'] - -// ---------- 颜色数学 ---------- -export function normalizeHex(value) { - const color = String(value ?? '').trim().toLowerCase() - - if (/^#[0-9a-f]{6}$/.test(color)) return color - if (/^#[0-9a-f]{3}$/.test(color)) { - const [red, green, blue] = color.slice(1) - return `#${red}${red}${green}${green}${blue}${blue}` - } - - return null -} - -export function hexToRgb(hex) { - return [ - Number.parseInt(hex.slice(1, 3), 16), - Number.parseInt(hex.slice(3, 5), 16), - Number.parseInt(hex.slice(5, 7), 16), - ] -} - -export function rgbToHex([red, green, blue]) { - return `#${[red, green, blue] - .map((channel) => Math.round(channel).toString(16).padStart(2, '0')) - .join('')}` -} - -export function rgbToHsl([red, green, blue]) { - const r = red / 255 - const g = green / 255 - const b = blue / 255 - const max = Math.max(r, g, b) - const min = Math.min(r, g, b) - const lightness = (max + min) / 2 - - if (max === min) return { h: 0, s: 0, l: lightness } - - const delta = max - min - const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min) - let hue - - if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0) - else if (max === g) hue = (b - r) / delta + 2 - else hue = (r - g) / delta + 4 - - return { h: hue / 6, s: saturation, l: lightness } -} - -function hueToRgb(p, q, t) { - let hue = t - if (hue < 0) hue += 1 - if (hue > 1) hue -= 1 - if (hue < 1 / 6) return p + (q - p) * 6 * hue - if (hue < 1 / 2) return q - if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6 - return p -} - -export function hslToRgb({ h, s, l }) { - if (s === 0) { - const value = Math.round(l * 255) - return [value, value, value] - } - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - - return [ - Math.round(hueToRgb(p, q, h + 1 / 3) * 255), - Math.round(hueToRgb(p, q, h) * 255), - Math.round(hueToRgb(p, q, h - 1 / 3) * 255), - ] -} - -export function adjustLightness(rgb, delta) { - const hsl = rgbToHsl(rgb) - return hslToRgb({ ...hsl, l: Math.min(1, Math.max(0, hsl.l + delta)) }) -} - -export function mixRgb(firstRgb, secondRgb, secondRatio) { - const firstRatio = 1 - secondRatio - return firstRgb.map((channel, index) => - Math.round(channel * firstRatio + secondRgb[index] * secondRatio), - ) -} - -export function getRelativeLuminance(rgb) { - const [red, green, blue] = rgb.map((channel) => { - const value = channel / 255 - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 - }) - - return red * 0.2126 + green * 0.7152 + blue * 0.0722 -} - -export function getContrastRatio(firstRgb, secondRgb) { - const firstLuminance = getRelativeLuminance(firstRgb) - const secondLuminance = getRelativeLuminance(secondRgb) - const lighter = Math.max(firstLuminance, secondLuminance) - const darker = Math.min(firstLuminance, secondLuminance) - - return (lighter + 0.05) / (darker + 0.05) -} - -// ---------- 主色派生 ---------- -function getForegroundRgb(themeRgb) { - const [light, dark] = FOREGROUND_CANDIDATES.map(hexToRgb) - const lightContrast = getContrastRatio(themeRgb, light) - const darkContrast = getContrastRatio(themeRgb, dark) - - return lightContrast >= darkContrast ? light : dark -} - -function ensurePrimaryContrast(rgb, backgroundRgb) { - if (getContrastRatio(rgb, backgroundRgb) >= ALGORITHM.minContrast) return rgb - - const sourceLightness = rgbToHsl(rgb).l - - for (let step = 1; step <= 100; step += 1) { - const delta = step / 100 - const candidates = [adjustLightness(rgb, -delta), adjustLightness(rgb, delta)] - const candidate = candidates.find( - (item) => getContrastRatio(item, backgroundRgb) >= ALGORITHM.minContrast, - ) - - if (candidate) return candidate - } - - return sourceLightness >= 0.5 ? adjustLightness(rgb, -0.5) : adjustLightness(rgb, 0.5) -} - -export function createPrimaryTokens(color, mode) { - const sourceRgb = hexToRgb(color) - const backgroundRgb = hexToRgb(SURFACES[mode]) - const primaryRgb = ensurePrimaryContrast(sourceRgb, backgroundRgb) - const primaryLightness = rgbToHsl(primaryRgb).l - const direction = - mode === 'dark' ? (primaryLightness > 0.85 ? -1 : 1) : primaryLightness < 0.15 ? 1 : -1 - const hoverRgb = ensurePrimaryContrast( - adjustLightness(primaryRgb, direction * ALGORITHM.hoverDelta), - backgroundRgb, - ) - const activeRgb = ensurePrimaryContrast( - adjustLightness(primaryRgb, direction * ALGORITHM.activeDelta), - backgroundRgb, - ) - const primaryBorderRgb = mixRgb(primaryRgb, backgroundRgb, ALGORITHM.borderMixRatio) - - return { - primary: rgbToHex(primaryRgb), - 'primary-foreground': rgbToHex(getForegroundRgb(primaryRgb)), - 'primary-hover': rgbToHex(hoverRgb), - 'primary-active': rgbToHex(activeRgb), - 'primary-border': rgbToHex(primaryBorderRgb), - ring: rgbToHex(primaryRgb), - } -} diff --git a/code/fms/fms-vue/src/theme/index.js b/code/fms/fms-vue/src/theme/index.js new file mode 100644 index 00000000..cbbaeaa3 --- /dev/null +++ b/code/fms/fms-vue/src/theme/index.js @@ -0,0 +1,15 @@ +// 主题应用纯函数 —— 操作 上的 class 与 CSS 变量 + +const root = () => document.documentElement + +// 写入主色变量 +export function applyPrimaryColor(color) { + if (color) { + root().style.setProperty('--fms-primary', color) + } +} + +// 切换深浅色 class +export function applyDarkMode(isDark) { + root().classList.toggle('dark', !!isDark) +} diff --git a/code/fms/fms-vue/src/theme/presets.js b/code/fms/fms-vue/src/theme/presets.js new file mode 100644 index 00000000..54c750a8 --- /dev/null +++ b/code/fms/fms-vue/src/theme/presets.js @@ -0,0 +1,15 @@ +// 预设主色色板 —— 颜色值单源 +export const PRESETS = [ + { name: '墨蓝', color: '#0f172b' }, + { name: '拂晓蓝', color: '#1677ff' }, + { name: '青碧', color: '#06b6d4' }, + { name: '翠绿', color: '#16a34a' }, + { name: '琥珀', color: '#f59e0b' }, + { name: '朱砂', color: '#ef4444' }, + { name: '暮紫', color: '#8b5cf6' }, + { name: '玫红', color: '#ec4899' }, + { name: '墨青', color: '#0f766e' }, +] + +// 默认主色(与 store 初始值一致) +export const DEFAULT_PRIMARY = PRESETS[0].color diff --git a/code/fms/fms-vue/src/theme/tokens.css b/code/fms/fms-vue/src/theme/tokens.css new file mode 100644 index 00000000..ee848c99 --- /dev/null +++ b/code/fms/fms-vue/src/theme/tokens.css @@ -0,0 +1,151 @@ +/* ============================================================ + * Design Tokens —— CSS 变量唯一运行时真相 + * 浅色定义于 :root,深色通过 .dark 覆盖同一批变量。 + * 主色 --fms-primary 由 JS 写入,衍生色用 color-mix() 派生。 + * ============================================================ */ + +:root { + /* 中性色:背景 / 卡片 / 文字 / 次级 / 边框 */ + --fms-background: #f5f6f8; + --fms-card: #ffffff; + --fms-text: #1f2329; + --fms-secondary: #e5e6eb; + --fms-secondary-hover: #f1f5f9; + --fms-text-secondary: #86909c; + --fms-border: #e5e6eb; + + /* 禁用态(disabled) */ + --fms-disabled-bg: #f2f3f5; + --fms-disabled-text: #c9cdd4; + --fms-disabled-border: #e5e6eb; + + /* 固定语义色 */ + --fms-success: #16a34a; + --fms-success-text: #ffffff; + --fms-warning: #f59e0b; + --fms-warning-text: #1f2329; + --fms-danger: #ef4444; + --fms-danger-text: #ffffff; + + /* 主色系列:--fms-primary 由 JS 写入,其余派生 */ + --fms-primary: #0f172b; + --fms-primary-text: #ffffff; + --fms-primary-hover: color-mix(in srgb, var(--fms-primary) 88%, #000); + --fms-primary-active: color-mix(in srgb, var(--fms-primary) 76%, #000); + + /* 字体 */ + --fms-font-size: 14px; + --fms-line-height: 20px; + + /* 控件 */ + --fms-control-height: 32px; + --fms-control-padding-x: 10px; + --fms-control-radius: 6px; + --fms-control-size: 16px; + + /* 开关(switch)尺寸(不随主题变化) */ + --fms-switch-width: 32px; + --fms-switch-height: 18px; + --fms-switch-thumb-size: 14px; + --fms-switch-thumb-offset: 2px; + + /* 下拉(select)浮层列表最大高度(不随主题变化) */ + --fms-select-dropdown-max-height: 256px; + + /* 下拉菜单(dropdown)尺寸(不随主题变化) */ + --fms-dropdown-min-width: 160px; + --fms-dropdown-max-height: 256px; + --fms-dropdown-item-padding-y: 5px; + --fms-dropdown-item-padding-x: 8px; + --fms-dropdown-item-radius: 4px; + --fms-dropdown-divider-margin: 4px 0; + + /* 标签(tag)尺寸(不随主题变化) */ + --fms-tag-height: 20px; + --fms-tag-font-size: 12px; + --fms-tag-padding-x: 8px; + --fms-tag-gap: 4px; + + /* 加载(spin)尺寸(不随主题变化) */ + --fms-spin-size: 20px; + --fms-spin-description-gap: 8px; + + /* 表单(form):label 宽度 / 表单项间距 / 辅助提示字号(不随主题变化) */ + --fms-form-label-width: 88px; + --fms-form-item-margin-bottom: 18px; + --fms-form-help-font-size: 12px; + + /* 阴影 */ + --fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + + /* 浮层(popover / tooltip 等) */ + --fms-popover-padding: 8px; + --fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.12); + --fms-z-popover: 1000; + + /* 轻提示 / 通知(message / notification) */ + --fms-feedback-icon-size: 18px; + --fms-feedback-offset: 24px; + --fms-z-message: 2000; + --fms-z-notification: 2000; + + /* 弹窗(modal / drawer) */ + --fms-mask-bg: rgba(0, 0, 0, 0.45); + --fms-z-modal: 3000; + --fms-modal-padding: 16px 20px; + --fms-drawer-header-padding: 12px 20px; + --fms-drawer-body-padding: 12px 20px; + --fms-drawer-footer-padding: 8px 20px; + + /* 分割(splitter):命中区 / 视觉条 / hover 线宽 / 中央手柄(不随主题变化) */ + /* --fms-splitter-bar-size-hover 同时被 modal / drawer 缩放手柄的拖拽线引用 */ + --fms-splitter-trigger-size: 6px; + + /* 栅格(grid):默认水平/垂直间距(不随主题变化) */ + --fms-grid-gutter: 0px; + --fms-splitter-bar-size: 2px; + --fms-splitter-bar-size-hover: 4px; + --fms-splitter-handle-size: 20px; + + /* 树(tree):节点高度 / 缩进 / 图标 / 拖拽指示线(不随主题变化) */ + --fms-tree-node-height: 32px; + --fms-tree-indent: 18px; + --fms-tree-icon-size: 16px; + --fms-tree-switcher-size: 22px; + --fms-tree-gutter-x: 4px; + --fms-tree-title-padding-x: 6px; + --fms-tree-grip-size: 16px; + --fms-tree-drop-line-size: 2px; +} + +.dark { + /* 中性色 */ + --fms-background: #0f1115; + --fms-card: #1a1d24; + --fms-text: #e6e8eb; + --fms-secondary: #262a33; + --fms-secondary-hover: #262a33; + --fms-text-secondary: #9aa3af; + --fms-border: #2a2f3a; + + /* 禁用态 */ + --fms-disabled-bg: #262a33; + --fms-disabled-text: #5b6472; + --fms-disabled-border: #2a2f3a; + + /* 固定语义色(深色下提亮) */ + --fms-success: #22c55e; + --fms-success-text: #0f1115; + --fms-warning: #fbbf24; + --fms-warning-text: #1f2329; + --fms-danger: #f87171; + --fms-danger-text: #0f1115; + + /* 主色派生方向反转:深色下 hover 提亮 */ + --fms-primary-hover: color-mix(in srgb, var(--fms-primary) 88%, #fff); + --fms-primary-active: color-mix(in srgb, var(--fms-primary) 76%, #fff); + + /* 阴影(深色下加重投影) */ + --fms-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.5); +} diff --git a/code/fms/fms-vue/src/theme/useTheme.js b/code/fms/fms-vue/src/theme/useTheme.js deleted file mode 100644 index d458ca9b..00000000 --- a/code/fms/fms-vue/src/theme/useTheme.js +++ /dev/null @@ -1,72 +0,0 @@ -import { ref } from 'vue' -import { - createPrimaryTokens, - DEFAULT_COLOR, - DEFAULT_MODE, - normalizeHex, - STORAGE_KEYS, -} from './color' - -const theme = ref(DEFAULT_MODE) -const themeColor = ref(DEFAULT_COLOR) - -function applyTheme() { - const root = document.documentElement - const tokens = createPrimaryTokens(themeColor.value, theme.value) - - root.dataset.theme = theme.value - root.style.colorScheme = theme.value - - for (const [name, value] of Object.entries(tokens)) { - root.style.setProperty(`--${name}`, value) - } -} - -function readStorage(key) { - try { - return window.localStorage.getItem(key) - } catch { - return null - } -} - -function writeStorage(key, value) { - try { - window.localStorage.setItem(key, value) - } catch { - // storage 不可用时主题仍生效。 - } -} - -export function initTheme() { - const storedMode = readStorage(STORAGE_KEYS.mode) - const storedColor = normalizeHex(readStorage(STORAGE_KEYS.color)) - - theme.value = storedMode === 'dark' ? 'dark' : 'light' - themeColor.value = storedColor ?? DEFAULT_COLOR - applyTheme() -} - -function setTheme(value) { - theme.value = value === 'dark' ? 'dark' : 'light' - applyTheme() - writeStorage(STORAGE_KEYS.mode, theme.value) -} - -function setThemeColor(value) { - const color = normalizeHex(value) - if (!color) return false - - themeColor.value = color - applyTheme() - writeStorage(STORAGE_KEYS.color, color) - return true -} - -function toggleTheme() { - setTheme(theme.value === 'dark' ? 'light' : 'dark') -} - -export function useTheme() { - return { theme, themeColor, setTheme, setThemeColor, toggleTheme } -} diff --git a/code/fms/fms-vue/src/utils/lucideIcons.js b/code/fms/fms-vue/src/utils/lucideIcons.js new file mode 100644 index 00000000..40f2acb5 --- /dev/null +++ b/code/fms/fms-vue/src/utils/lucideIcons.js @@ -0,0 +1,29 @@ +import * as LucideIcons from '@lucide/vue' + +let iconMap = null + +function loadIcons() { + if (iconMap) return + const map = {} + for (const [key, component] of Object.entries(LucideIcons)) { + if ( + key !== 'default' && + key !== 'createLucideIcon' && + typeof key === 'string' && + key.charAt(0) === key.charAt(0).toUpperCase() + ) { + map[key] = component + } + } + iconMap = map +} + +/** + * 同步获取图标组件,传入 null/空字符串时返回 null。 + * @param {string | null | undefined} name lucide 图标名(首字母大写),如 "LayoutGrid" + */ +export function getIcon(name) { + if (!iconMap) loadIcons() + if (!name || !iconMap) return null + return iconMap[name] || null +} diff --git a/code/fms/fms-vue/src/utils/tree.js b/code/fms/fms-vue/src/utils/tree.js new file mode 100644 index 00000000..079765a5 --- /dev/null +++ b/code/fms/fms-vue/src/utils/tree.js @@ -0,0 +1,55 @@ +/** + * 根据 parentKey 将一维数组构建为树。 + * @param {Array} list 原始列表 + * @param {string} idKey 节点 ID 字段名,默认 "b_id" + * @param {string} parentKey 父节点关联字段名,默认 "b_parent_id" + * @param {string} childrenKey 子节点字段名,默认 "children" + */ +export function buildTree( + list, + idKey = 'b_id', + parentKey = 'b_parent_id', + childrenKey = 'children', +) { + const map = new Map() + const roots = [] + + for (const node of list) { + map.set(String(node[idKey]), node) + } + + for (const node of list) { + const pid = node[parentKey] + if (pid != null) { + const parent = map.get(String(pid)) + if (parent) { + if (!parent[childrenKey]) parent[childrenKey] = [] + parent[childrenKey].push(node) + continue + } + } + roots.push(node) + } + + return roots +} + +/** + * 按 b_xh 升序、b_id 升序 递归排序树。 + * @param {Array} tree 树形数组 + * @param {string} xhKey 排序权重字段名,默认 "b_xh" + * @param {string} idKey 排序兜底字段名,默认 "b_id" + */ +export function sortTree(tree, xhKey = 'b_xh', idKey = 'b_id') { + tree.sort((a, b) => { + const xhDiff = (Number(a[xhKey]) || 0) - (Number(b[xhKey]) || 0) + if (xhDiff !== 0) return xhDiff + return String(a[idKey] || '').localeCompare(String(b[idKey] || '')) + }) + for (const node of tree) { + if (node.children?.length) { + sortTree(node.children, xhKey, idKey) + } + } + return tree +} diff --git a/code/fms/fms-vue/src/views/dashboard/index.vue b/code/fms/fms-vue/src/views/dashboard/index.vue new file mode 100644 index 00000000..019f57af --- /dev/null +++ b/code/fms/fms-vue/src/views/dashboard/index.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/demo/index.vue b/code/fms/fms-vue/src/views/demo/index.vue new file mode 100644 index 00000000..8d2890af --- /dev/null +++ b/code/fms/fms-vue/src/views/demo/index.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/login/index.vue b/code/fms/fms-vue/src/views/login/index.vue new file mode 100644 index 00000000..41f4ceb6 --- /dev/null +++ b/code/fms/fms-vue/src/views/login/index.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/code/fms/fms-vue/vite.config.js b/code/fms/fms-vue/vite.config.js index 3e817721..f9626443 100644 --- a/code/fms/fms-vue/vite.config.js +++ b/code/fms/fms-vue/vite.config.js @@ -12,6 +12,13 @@ export default defineConfig({ host: true, port: 5082, strictPort: true, + // 开发环境将 /api 请求代理到后端服务(与 fms-vue-old2 保持一致) + proxy: { + '/api': { + target: 'http://127.0.0.1:8088', + changeOrigin: true, + }, + }, }, plugins: [vue()], }) diff --git a/code/fms/fms-vue/开发规范.md b/code/fms/fms-vue/开发规范.md new file mode 100644 index 00000000..bd668290 --- /dev/null +++ b/code/fms/fms-vue/开发规范.md @@ -0,0 +1,305 @@ +# FMS Vue AI 组件库开发规范 + +本文档是 AI 修改或新增 `fms-vue` UI 组件时的默认约束。目标是保持组件库稳定、轻量、可预测,并把性能放在功能完成之前。 + +## 1. 优先级 + +按以下顺序做技术决策: + +1. 正确性和现有 API 行为 +2. 性能和资源释放 +3. 组件写法一致 +4. 可访问性和交互反馈 +5. 视觉细节 + +当需求与现有约定冲突时,优先保持组件库的一致性;确需破坏 API 时,必须在回复中明确说明。 + +## 2. AI 工作边界 + +- 默认只阅读、修改和静态检查与需求相关的源码。 +- 不主动执行构建、测试、预览服务或大范围格式化。 +- 只有用户明确要求时,才执行 `pnpm build`、测试命令或启动开发服务。 +- 不修改用户已有的无关改动,不删除生成目录,不重置工作区。 +- 修改前先检查目标文件和相邻组件,避免重复抽象或引入第二套写法。 +- 完成后只报告实际修改内容和静态检查结果;没有执行构建或测试时必须明确说明。 + +### 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 ` + + + + +``` + +### 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 + +``` + +分块规则: + +- 公共逻辑放在所有模式专属逻辑之前,公共逻辑不能依赖某个模式的 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. 只做相关文件的静态检查和格式整理,不主动构建或测试。 +7. 回复中列出变更文件、性能处理、未执行的构建/测试,以及仍存在的已知风险。 + +## 10. 交付检查清单 + +- [ ] API 是否只包含需求明确要求的 Props 和事件? +- [ ] 枚举 Props 是否有 `validator`? +- [ ] 是否避免了旧 API 兼容分支和重复状态? +- [ ] 高频事件是否节流或使用 `requestAnimationFrame`? +- [ ] 是否避免在事件循环中反复读取布局? +- [ ] 所有全局监听器、计时器和动画帧是否在结束和卸载时清理? +- [ ] 公用逻辑是否按规则放在 `src/components/ui/utils`? +- [ ] CSS 是否复用 token,且没有无必要的嵌套和重复规则? +- [ ] 是否未添加 `size` 属性,尺寸/大小均复用 `--fms-*` token(缺失时已补充到 `tokens.css`)? +- [ ] 图标按钮、焦点状态和键盘操作是否可用? +- [ ] 是否遵守“不主动构建和测试”的工作边界,并在结果中说明? diff --git a/code/fms/layout-fix1.png b/code/fms/layout-fix1.png new file mode 100644 index 00000000..c8404b73 Binary files /dev/null and b/code/fms/layout-fix1.png differ diff --git a/code/fms/layout-fix2.png b/code/fms/layout-fix2.png new file mode 100644 index 00000000..65a6a3e9 Binary files /dev/null and b/code/fms/layout-fix2.png differ diff --git a/code/fms/layout-new.png b/code/fms/layout-new.png new file mode 100644 index 00000000..16ad35eb Binary files /dev/null and b/code/fms/layout-new.png differ diff --git a/code/fms/layout.png b/code/fms/layout.png new file mode 100644 index 00000000..517945c8 Binary files /dev/null and b/code/fms/layout.png differ diff --git a/code/fms/layout2.png b/code/fms/layout2.png new file mode 100644 index 00000000..a9896551 Binary files /dev/null and b/code/fms/layout2.png differ diff --git a/code/fms/layout3.png b/code/fms/layout3.png new file mode 100644 index 00000000..e607579d Binary files /dev/null and b/code/fms/layout3.png differ diff --git a/code/fms/layout4.png b/code/fms/layout4.png new file mode 100644 index 00000000..c19bb18b Binary files /dev/null and b/code/fms/layout4.png differ diff --git a/code/fms/layout5.png b/code/fms/layout5.png new file mode 100644 index 00000000..b32cb18c Binary files /dev/null and b/code/fms/layout5.png differ diff --git a/code/fms/old2-layout.png b/code/fms/old2-layout.png new file mode 100644 index 00000000..7b52b0f5 Binary files /dev/null and b/code/fms/old2-layout.png differ diff --git a/code/fms/snap.yaml b/code/fms/snap.yaml new file mode 100644 index 00000000..d48fd68f --- /dev/null +++ b/code/fms/snap.yaml @@ -0,0 +1,32 @@ +- generic [ref=f5e3]: + - complementary [ref=f5e4]: + - generic [ref=f5e5]: + - button "金地集团 企业版" [ref=f5e8] [cursor=pointer]: + - generic [ref=f5e12]: + - generic [ref=f5e13]: 金地集团 + - generic [ref=f5e14]: 企业版 + - navigation "主导航" [ref=f5e19]: + - link "工作台" [ref=f5e20] [cursor=pointer]: + - /url: /dashboard + - button "基础资料" [ref=f5e85] [cursor=pointer] + - button "系统管理" [ref=f5e93] [cursor=pointer] + - generic "拖拽调整侧栏宽度" [ref=f5e28] + - generic [ref=f5e29]: + - banner [ref=f5e30]: + - generic [ref=f5e31]: + - button "折叠侧栏" [ref=f5e32] [cursor=pointer] + - navigation "面包屑" [ref=f5e36]: + - generic [ref=f5e37]: 工作台 + - generic [ref=f5e38]: + - button "English" [ref=f5e39] [cursor=pointer] + - button "主题色切换" [ref=f5e45] [cursor=pointer] + - button "切换到深色模式" [ref=f5e52] [cursor=pointer] + - button "通知" [ref=f5e59] [cursor=pointer] + - button "管 管理员" [ref=f5e65] [cursor=pointer]: + - generic [ref=f5e66]: 管 + - generic [ref=f5e67]: 管理员 + - tablist [ref=f5e71]: + - tab "工作台" [selected] [ref=f5e72] [cursor=pointer] + - generic [ref=f5e75]: + - heading "工作台" [level=1] [ref=f5e82] + - paragraph [ref=f5e83]: 布局壳与业务页面正在搭建中,敬请期待。 \ No newline at end of file diff --git a/code/fms/参考组件库/antdv-next b/code/fms/参考组件库/antdv-next new file mode 160000 index 00000000..ef2cda3f --- /dev/null +++ b/code/fms/参考组件库/antdv-next @@ -0,0 +1 @@ +Subproject commit ef2cda3fea0c8715ede69b75b565c00bb4b08e5b diff --git a/code/fms/参考组件库/shadcn b/code/fms/参考组件库/shadcn new file mode 160000 index 00000000..d4fc45b1 --- /dev/null +++ b/code/fms/参考组件库/shadcn @@ -0,0 +1 @@ +Subproject commit d4fc45b1fbabfccb7a6a4333d8004cf19481caa9