-
主题色
-
-
-
-
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ weekday }}
+
+
+
+
+
+ {{ cell.day }}
+
+
+
+
+
+
+
+ {{ year.label }}
+
+
+
+
+
+ {{ month.label }}
+
+
+
+
+
+
+
+
+ 开始
+
+
+
+ 结束
+
+
+
+
+
+
+
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()
+}
+
+/** 比较两日期:a
b → 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 @@
+
+
+
+
+
+
+
+ -
+ {{ optionLabel(field, value) }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
+
将多个按钮组合为关联操作,支持分隔线与垂直布局。
+
+
+ 基础用法
+
+
+ 新建
+ 编辑
+ 删除
+
+
+
+ 左
+
+ 中
+
+ 右
+
+
+
+ 垂直布局
+
+
+ 上
+ 中
+ 下
+
+
+
+ 分页样式
+
+
+ 10
+ 20
+ 50
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
+
保持克制的交互组件,优先保证可读性和响应速度。
+
+
+ 基础用法
+
+ 主要操作
+ 次要操作
+ 描边按钮
+ 低强调
+ 危险操作
+ 链接按钮
+
+
+ 禁用态
+
+ 默认禁用
+ 次要禁用
+ 描边禁用
+ 危险禁用
+
+
+ 加载态
+
+ 提交中
+ 保存中
+ 加载中
+
+
+ 通栏
+
+ 通栏按钮
+
+
+ 图标按钮
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Checkbox 复选框
+
多选控件,支持半选(indeterminate)与禁用态。
+
+
+ 基础用法
+
+ 同意服务条款与隐私政策
+ 订阅产品动态邮件
+
+
+ 禁用态
+
+ 未选中禁用
+ 选中禁用
+
+
+ 半选(全选场景)
+
+ 全选
+ 选项 A
+ 选项 B
+ 选项 C
+
+
+
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 @@
+
+
+
+
+
+
Components
+
DatePicker 日期选择
+
+ format 同时决定选择面板、选择精度和展示格式;valueFormat 仅控制绑定值格式,
+ 未设置时自动跟随 format。
+
+
+
+ 年、月、年月
+
+
+
+
+
+
+ 单个日期(纯日期)
+
+
+
+
+
+
+ 日期时间(format 驱动)
+
+
+
+
+
+ 范围选择(纯日期)
+
+
+
+
+
+ 范围选择含时间
+
+
+
+
+ 禁用态
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Drawer 抽屉
+
从屏幕四边滑入的侧边面板,适合承载表单或详情,支持受控与非受控两种用法。
+
+
+ 四个方向
+
+ 左侧
+ 右侧
+ 顶部
+ 底部
+
+
+ 自定义头部与底部
+
+ 自定义内容
+
+
+ 无遮罩
+
+ 无遮罩抽屉
+
+
+
+ 抽屉从{{ drawerPlacement }}侧滑入,拖动边缘可调整尺寸;点击遮罩、关闭按钮或按 Esc 均可关闭。
+ 适用于表单填写、详情查看等需要更大空间的场景。
+
+
+
+
+ 帮助
+
+ 头部左侧通过 #extra 插槽放入附加操作,底部通过 #footer 插槽放置按钮。
+
+ 取消
+ 确定
+
+
+
+
+ 使用 :mask="false" 隐藏遮罩,抽屉滑入时页面保持可交互。
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Dropdown 下拉菜单
+
数据驱动菜单项,支持点击 / 悬停 / 右键触发、多级子菜单与键盘导航。
+
+
+ 基础用法
+
+
+ 操作
+
+
+ 更多操作
+
+
+
+ 分组、分隔线与危险项
+
+
+ 用户菜单
+
+
+
+ 禁用项
+
+
+ 项目管理
+
+
+
+ 多级子菜单
+
+
+ 团队
+
+
+
+ hover 触发
+
+
+ 悬停打开
+
+
+
+ 右键触发
+
+
+ 受控与自定义内容
+
+
+ 受控菜单
+
+
打开
+
关闭
+
+
+ 自定义内容
+
+
+
自定义菜单内容,不依赖 items。
+
知道了
+
+
+
+
+
+ 最近操作:{{ lastAction }}
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
+
轻量级表单组件,支持横向 / 纵向布局、基础校验规则与提交 / 重置事件。
+
+
+ 基础用法(横向布局)
+
+
+ 纵向布局
+
+
+ 自定义校验(validator)
+
+
+ 校验提示(Select / DatePicker / RangePicker)
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Grid 栅格
+
24 栅格布局容器,用于快速搭建页面结构:Row 控制行间距与对齐,Col 控制占位与偏移。
+
+
+ 基础栅格(每列 1/3)
+
+
+
+ span 8
+ span 8
+ span 8
+
+
+
+
+ gutter 水平 + 垂直间距 [24, 16]
+
+
+
+ span 6
+ span 6
+ span 6
+ span 6
+ span 6
+ span 6
+
+
+
+
+ 对齐 align="middle" + justify="space-between"
+
+
+
+ span 4
+ span 4
+ span 4
+
+
+
+
+ 偏移 offset(左侧空出 8 格)
+
+
+
+ offset 8 / span 8
+
+
+
+
+ 排序 order(DOM 顺序 3-1-2,视觉顺序 1-2-3)
+
+
+
+ order 3
+ order 1
+ order 2
+
+
+
+
+ push / pull(两列交换位置)
+
+
+
+ push 8(移到右侧)
+ pull 8(移到左侧)
+
+
+
+
+ flex(第一列固定 100px,其余均分剩余)
+
+
+
+ flex 100px
+ flex auto
+ flex auto
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Message 轻提示
+
页面顶部居中的轻量反馈,用于操作结果提示,默认自动消失,不阻塞交互。
+
+
+ 出现位置(9 方位)
+
+
+ {{ p }}
+
+
+
+ 四种类型
+
+ Info
+ Success
+ Warning
+ Error
+
+
+ 可手动关闭
+
+
+ 常驻可关闭
+
+
+
+ 多条堆叠
+
+ 连续触发 3 条
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Modal 模态框
+
居中弹窗,支持遮罩关闭、Esc 关闭与底部按钮区,聚焦会约束在弹窗内部。
+
+
+ 基础用法
+
+ 打开模态框
+
+
+ 垂直居中 / 无遮罩
+
+ 垂直居中
+ 无遮罩
+
+
+ 自定义底部(#footer 插槽)
+
+ 自定义底部
+
+
+ 异步提交(confirmLoading)
+
+ 提交后自动关闭
+
+
+ 受控模式 + 关闭后反馈
+
+ 保存并提示
+
+
+
+ 这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按 Esc 均可关闭。
+
+
+
+ 使用 centered 属性让弹窗在视口垂直居中显示,适合短内容。
+
+
+
+ 使用 :mask="false" 隐藏遮罩,弹窗仍可交互,但不再有背景变暗效果。
+
+
+
+ 通过 #footer 插槽替换默认的确定/取消按钮。
+
+ 稍后再说
+ 立即删除
+
+
+
+
+ 确定按钮进入加载态,加载期间所有关闭方式(遮罩/Esc/关闭/取消)均被阻止,避免中断提交。
+
+
+
+ 点击「保存」后自动关闭,并触发一条成功提示。
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Notification 通知
+
右上角通知卡片,承载标题与描述信息,支持手动关闭与自动消失,可堆叠。
+
+
+ 出现位置(9 方位)
+
+
+ {{ p }}
+
+
+
+ 四种类型
+
+ Info
+ Success
+ Warning
+ Error
+
+
+ 多条堆叠与全部关闭
+
+ 连续触发 3 条
+ 关闭全部
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
+
页码窗口折叠、每页条数切换、快速跳转与总数显示,支持受控与非受控两种用法。
+
+
+ 基础用法(非受控)
+
+
+ 受控模式(v-model:current)
+
+
+
当前页:{{ paginationPage }}
+
+
+ 每页条数切换(showSizeChanger)
+
+
+
+ 每页 {{ paginationSizeValue }} 条,共 {{ paginationSizeTotalPages }} 页
+
+
+
+ 快速跳转(showQuickJumper)
+
+
+ 总数显示(showTotal 与自定义插槽)
+
+
+
+
+ 第 {{ from }}-{{ to }} 条,共 {{ total }} 条
+
+
+
+
+ 禁用
+
+
+ 单页隐藏(hideOnSinglePage)
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Popover 气泡卡片
+
浮层基座,承载 Tooltip、Select、Dropdown 等后续组件的定位与交互。
+
+
+ 基础用法
+
+
+ 点击打开
+
+ 标题
+ 这是一段浮层内容,用于说明当前操作。
+
+
+
+ 悬停打开
+
+ hover 触发的浮层,移入浮层内不会关闭。
+
+
+
+
+
+ focus 触发,失焦后关闭。
+
+
+
+
+ 十二方位
+
+
+ {{ p }}
+
+ {{ p }}
+
+
+
+
+ 携带表单内容
+
+
+ 筛选
+
+
+
+ 应用
+
+
+
+
+
+ 禁用与受控
+
+
+ 禁用
+ 禁用后不响应触发
+
+
+ manual 受控
+ 由外部状态控制开关
+
+
打开
+
关闭
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Radio 单选框
+
在多个互斥选项中选择其一,支持方向键导航。
+
+
+ 基础用法
+
+
+ 苹果
+ 香蕉
+ 橙子
+
+
+
+ 禁用态
+
+
+ 小
+ 中
+ 大(禁用)
+
+
+ 整组禁用 A
+ 整组禁用 B
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Select 下拉选择
+
+ 基础单选下拉,支持 options
+ 数组与插槽两种数据源、筛选(默认开启,可自定义)、清除、禁用与键盘导航。
+
+
+
+ 基础用法
+
+
+
+
+
+ 清除与禁用
+
+
+
+
+
+
+ 筛选(默认开启)
+
+
+
+
+
+ 关闭筛选
+
+
+
+
+ 远程加载
+
+
+
+
+ 自由输入(allowFreeInput)
+
+
+
+
+
+ 自定义字段名(labelKey / valueKey)
+
+
+
+
+ 插槽自定义选项
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Spin 加载中
+
反馈加载状态:独立指示器,或包裹内容并覆盖轻量遮罩,加载期间内部布局保持不变。
+
+
+ 基础指示器(形态一)
+
+
+
+
+ 带文案
+
+
+
+
+ 自定义指示器(#indicator 插槽)
+
+
+
+
+
+
+
+
+ 延迟显示(delay,防短请求闪烁)
+
+
+ 包裹内容(形态二,内部布局不变)
+
+
+ 加载状态
+
+
+
+
+
+
+
+ 服务列表
+ 基准
+
+
+
+
+ | 服务 |
+ 版本 |
+ 状态 |
+
+
+
+
+ | {{ row.name }} |
+ {{ row.version }} |
+ {{ row.status }} |
+
+
+
+
+
+
+
+
+
+ 服务列表
+ Spin
+
+
+
+
+ | 服务 |
+ 版本 |
+ 状态 |
+
+
+
+
+ | {{ row.name }} |
+ {{ row.version }} |
+ {{ row.status }} |
+
+
+
+
+
+
+
+
同一卡片在未包裹与包裹 Spin(含加载中)两种状态下尺寸一致,内部元素位置不偏移。
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Splitter 分割面板
+
水平或垂直分割容器,拖动分隔条调整各面板尺寸,支持固定尺寸、最小/最大约束与键盘方向键操作。
+
+
+ 水平分割(首尾固定,中间自适应)
+
+
+
+ 左侧 200px
+
+
+ 中间自适应
+
+
+ 右侧 200px
+
+
+
+
+ 垂直分割(百分比尺寸)
+
+
+
+ 上 40%
+
+
+ 下自适应
+
+
+
+
+ min / max 约束
+
+
+
+ min 120 / max 240
+
+
+ 左侧当前:{{ splitterSizes[0] }}px
+
+
+
+
+ hover 时显示分割线
+
+
+
+ 平时无分割线
+
+
+ 悬停或聚焦分隔条时显示
+
+
+
+
+ 禁用调整
+
+
+
+ 不可调整
+
+
+ 不可调整
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Switch 开关
+
表示两种互斥状态之间的切换。
+
+
+ 基础用法
+
+ 接收通知
+ 自动保存
+
+
+ 禁用态
+
+ 选中禁用
+ 未选中禁用
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Tabs 标签页
+
line 线型标签页,支持 TabPane 插槽与 items 数组两种数据源、左右侧边布局与键盘方向键导航。
+
+
+ 基础用法(TabPane 插槽)
+
+
+
+ 项目进度 68%,当前迭代还有 3 个里程碑待评审。
+
+
+ 共 12 位成员,其中 8 位本周活跃,2 位在休假。
+
+
+ 最近一次发布在 2 小时前,包含 3 个缺陷修复。
+
+
+
+
+ items 数组模式
+
+
+
+
+ 自定义标签(#label 插槽)
+
+
+
+ 概览
+ 项目进度 68%,当前迭代还有 3 个里程碑待评审。
+
+
+ 消息
+ 共 12 位成员,其中 8 位本周活跃,2 位在休假。
+
+
+
+
+ 标签栏居中(centered)
+
+
+
+ top 方向:标签列表水平居中显示。
+
+
+ 标签较少时列表整体居中。
+
+
+
+
+ left 方向:标签列表垂直居中显示。
+
+
+ 标签较少时列表整体垂直居中。
+
+
+
+
+ 侧边方向(placement)
+
+
+
+ 左侧布局:标签纵向排列,内容区自适应剩余宽度。
+
+
+ 设置面板用于调整项目参数与通知偏好。
+
+
+
+
+ 右侧布局:标签在右,内容靠左对齐。
+
+
+ 设置面板用于调整项目参数与通知偏好。
+
+
+
+
+ 禁用
+
+
+
+ 可访问的内容面板。
+
+
+ 此标签被禁用,不可切换。
+
+
+ 最近一次发布在 2 小时前,包含 3 个缺陷修复。
+
+
+
+
+ 整组禁用时所有标签不可交互。
+
+
+ 整组禁用时所有标签不可交互。
+
+
+
+
+ 懒渲染
+
+
+
+ 内容在激活时首次渲染,切换后保留,不重复创建。
+
+
+ 未激活的面板不会渲染到 DOM,切换回来内容仍保留。
+
+
+
+
+ 标签栏附加内容(leftExtra / rightExtra 插槽)
+
+
+
+
+
+
+ 右侧操作
+
+
+ 标签栏两端可插入额外内容,不参与 tab 键盘导航。
+
+
+ 左侧插槽渲染在标签之前,右侧插槽渲染在标签之后。
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Tag 标签
+
胶囊小标签,用于分类、状态与标记;支持变体、图标、可关闭与链接。
+
+
+ 基础变体
+
+ 默认
+ 次要
+ 描边
+ 危险
+ 幽灵
+
+
+ 图标标签
+
+
+
+ AI 生成
+
+
+
+ 精选
+
+
+
+ 可关闭
+
+
+ {{ tag }}
+
+ 全部已关闭
+
+
+ 禁用态
+
+ 默认禁用
+ 描边禁用
+ 危险禁用
+ 不可关闭
+
+
+ 链接标签
+
+ Vue
+ Vite
+
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
+
hover 触发的轻量提示,支持纯文本与富内容、十二方位、箭头开关与禁用。
+
+
+ 基础用法
+
+
+ 悬停查看提示
+
+
+ 多行提示
+
+
+ 无箭头
+
+
+
+ 富内容
+
+
+
+ 富内容标题
+ 可以放标题与说明文字。
+
+ 悬停查看富内容
+
+
+
+ 十二方位
+
+
+ {{ p }}
+
+
+
+ 禁用
+
+
+ 禁用提示
+
+
+
+
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 @@
+
+
+
+
+
+
Components
+
Tree 树形控件
+
+ 层级数据展示与选择:支持展开折叠、单选/多选、复选框(半选联动)、异步加载、拖拽排序与关键词过滤。
+
+
+
+ 基础 · 展开与单选
+
+
+
+
+
+
+
selectedKeys:{{ selectedKeys }}
+
+
+
+ 复选框 · 父子联动与半选
+
+
+
+
checkedKeys(联动):{{ checkedKeys }}
+
+
+
+
strictCheckedKeys(严格):{{ strictCheckedKeys }}
+
+
+
+ 异步加载(lazy)
+
+
+
+
+ 拖拽排序(draggable)
+
+
+
+
+ 关键词过滤
+
+
+
+
+
+ 自定义 #title 插槽
+
+
+
+
+
+ {{ node.title }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+ {{ item.shortcut }}
+
+
+
+
+
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