-
撑满宽度
+
+
+ 占满整行
+ 占满整行(描边)
+
-
-
diff --git a/code/g3soft-libs/docs/examples/button/disabled.vue b/code/g3soft-libs/docs/examples/button/disabled.vue
new file mode 100644
index 00000000..51e8c690
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/disabled.vue
@@ -0,0 +1,14 @@
+
+
+
+
+ 主操作
+ 次要操作
+ 描边
+ 删除
+ 链接
+
+
diff --git a/code/g3soft-libs/docs/examples/button/event.vue b/code/g3soft-libs/docs/examples/button/event.vue
new file mode 100644
index 00000000..0678596d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/event.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+ 点我(可按住 Ctrl)
+ 也是点我
+
+ 最近一次:{{ last }}
+
+
diff --git a/code/g3soft-libs/docs/examples/button/global-defaults.vue b/code/g3soft-libs/docs/examples/button/global-defaults.vue
new file mode 100644
index 00000000..c721d1f4
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/global-defaults.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ 默认变体
+
+
+
+
+
+ 继承全局默认
+ 显式 prop 优先
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/button/html-type.vue b/code/g3soft-libs/docs/examples/button/html-type.vue
new file mode 100644
index 00000000..e9f551f7
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/html-type.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+ 提交
+ 重置
+ 普通按钮
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/button/icon.vue b/code/g3soft-libs/docs/examples/button/icon.vue
index bdd036a9..dfe4226e 100644
--- a/code/g3soft-libs/docs/examples/button/icon.vue
+++ b/code/g3soft-libs/docs/examples/button/icon.vue
@@ -1,11 +1,27 @@
-
- +
- 新增
-
- 🔍
+
+
+
+ 新增
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/button/loading.vue b/code/g3soft-libs/docs/examples/button/loading.vue
new file mode 100644
index 00000000..bc6ef1d2
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/loading.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+ {{ submitting ? '提交中' : '提交' }}
+
+
+
+ 加载中
+ 加载中
+
+
diff --git a/code/g3soft-libs/docs/examples/button/size.vue b/code/g3soft-libs/docs/examples/button/size.vue
index ab356e50..48f15e03 100644
--- a/code/g3soft-libs/docs/examples/button/size.vue
+++ b/code/g3soft-libs/docs/examples/button/size.vue
@@ -1,9 +1,12 @@
- 小
- 中
- 大
+
+ 小号
+ 默认
+ 大号
+
diff --git a/code/g3soft-libs/docs/examples/button/state.vue b/code/g3soft-libs/docs/examples/button/state.vue
deleted file mode 100644
index 7f670dbe..00000000
--- a/code/g3soft-libs/docs/examples/button/state.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- 点击加载
- 禁用
- 禁用(主要)
-
diff --git a/code/g3soft-libs/docs/examples/button/type.vue b/code/g3soft-libs/docs/examples/button/type.vue
deleted file mode 100644
index c707395b..00000000
--- a/code/g3soft-libs/docs/examples/button/type.vue
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
- 成功按钮
- 警告按钮
- 危险按钮
- 文字按钮
- 链接按钮
-
diff --git a/code/g3soft-libs/docs/examples/button/variant.vue b/code/g3soft-libs/docs/examples/button/variant.vue
new file mode 100644
index 00000000..d7886e4b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/button/variant.vue
@@ -0,0 +1,16 @@
+
+
+
+
+ 主操作
+ 次要操作
+ 描边
+ 低强调
+ 删除
+ 危险(描边)
+ 链接按钮
+
+
diff --git a/code/g3soft-libs/docs/examples/checkbox/basic.vue b/code/g3soft-libs/docs/examples/checkbox/basic.vue
new file mode 100644
index 00000000..ad995f31
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/checkbox/basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
我已阅读并同意服务条款
+
agreed = {{ agreed }}
+
+
diff --git a/code/g3soft-libs/docs/examples/checkbox/disabled.vue b/code/g3soft-libs/docs/examples/checkbox/disabled.vue
new file mode 100644
index 00000000..ab3f56b5
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/checkbox/disabled.vue
@@ -0,0 +1,12 @@
+
+
+
+
+ 未选中且禁用
+ 已选中且禁用
+ 半选且禁用
+
+
diff --git a/code/g3soft-libs/docs/examples/checkbox/indeterminate.vue b/code/g3soft-libs/docs/examples/checkbox/indeterminate.vue
new file mode 100644
index 00000000..0c7e03f4
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/checkbox/indeterminate.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ 全选({{ checkedKeys.length }} / {{ options.length }})
+
+
+
+
+ toggleOne(option.value, checked)"
+ >
+ {{ option.label }}
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/checkbox/rich-label.vue b/code/g3soft-libs/docs/examples/checkbox/rich-label.vue
new file mode 100644
index 00000000..726c3306
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/checkbox/rich-label.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+ 接收构建通知
+
+ 构建失败时通过邮件与站内信通知项目成员
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/date/date-basic.vue b/code/g3soft-libs/docs/examples/date/date-basic.vue
new file mode 100644
index 00000000..ada65e66
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/date-basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
绑定值:{{ date || '(空)' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/date/date-disabled.vue b/code/g3soft-libs/docs/examples/date/date-disabled.vue
new file mode 100644
index 00000000..88f4ff03
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/date-disabled.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/date/date-format.vue b/code/g3soft-libs/docs/examples/date/date-format.vue
new file mode 100644
index 00000000..40429322
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/date-format.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/date/manual-input.vue b/code/g3soft-libs/docs/examples/date/manual-input.vue
new file mode 100644
index 00000000..68724045
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/manual-input.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ 支持的输入:2026-10-08、2026/10/08、2026年10月8日、
+ 2026-10-08 09:30。部分输入(如只写 2026-10)会被补全。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/date/range-basic.vue b/code/g3soft-libs/docs/examples/date/range-basic.vue
new file mode 100644
index 00000000..2e58ceca
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/range-basic.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
(lastChange = value.join(' ~ '))"
+ />
+
+ 绑定值:{{ range[0] || '(空)' }} ~ {{ range[1] || '(空)' }}
+
+ change:{{ lastChange }}
+
+
diff --git a/code/g3soft-libs/docs/examples/date/time-select.vue b/code/g3soft-libs/docs/examples/date/time-select.vue
new file mode 100644
index 00000000..a234affd
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/date/time-select.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/drawer/basic.vue b/code/g3soft-libs/docs/examples/drawer/basic.vue
new file mode 100644
index 00000000..059c8a73
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/drawer/basic.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
打开抽屉
+
+
+ 抽屉内容区域可滚动,适合承载较长的表单或列表。
+ 拖动左边缘可以调整宽度。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/drawer/footer.vue b/code/g3soft-libs/docs/examples/drawer/footer.vue
new file mode 100644
index 00000000..7f38f8ff
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/drawer/footer.vue
@@ -0,0 +1,32 @@
+
+
+
+
+ 带底部动作
+
+
+
+ 已选 2 个条件
+
+
+
+
+
+
+
+
+
+ 重置
+ 应用筛选
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/drawer/placement.vue b/code/g3soft-libs/docs/examples/drawer/placement.vue
new file mode 100644
index 00000000..e4c5a18b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/drawer/placement.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+ {{ item }}
+
+
+
+ 左右方向用 width,上下方向用 height 控制尺寸。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/drawer/size.vue b/code/g3soft-libs/docs/examples/drawer/size.vue
new file mode 100644
index 00000000..9b6cb72a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/drawer/size.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+ 窄(360px)
+ 宽(640px)
+ 百分比(60%)
+
+ 拖拽:{{ resizable ? '开' : '关' }}
+
+
+
+
+ 打开(宽 {{ width }})
+
+
+
+ 当前宽度:{{ width }}。拖拽左边缘时只会改变宽度,不会影响高度。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/basic.vue b/code/g3soft-libs/docs/examples/dropdown/basic.vue
new file mode 100644
index 00000000..3ed6b072
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/basic.vue
@@ -0,0 +1,21 @@
+
+
+
+
+ 操作
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/declarative.vue b/code/g3soft-libs/docs/examples/dropdown/declarative.vue
new file mode 100644
index 00000000..448b6709
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/declarative.vue
@@ -0,0 +1,19 @@
+
+
+
+ G3Message.info(String(payload.key))">
+
+
+ 声明式菜单
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/header-slot.vue b/code/g3soft-libs/docs/examples/dropdown/header-slot.vue
new file mode 100644
index 00000000..8277e61d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/header-slot.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ 带头部的菜单
+
+
+
+
+
+
+ ZS
+
+
+
张三
+
zhangsan@g3soft.dev
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #menu 逃生舱
+
+
+
+ 这里可以放任意结构
+ 关闭
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/item-types.vue b/code/g3soft-libs/docs/examples/dropdown/item-types.vue
new file mode 100644
index 00000000..0ff2463e
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/item-types.vue
@@ -0,0 +1,33 @@
+
+
+
+
+ 完整菜单
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/keyboard.vue b/code/g3soft-libs/docs/examples/dropdown/keyboard.vue
new file mode 100644
index 00000000..1c6efb6c
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/keyboard.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ 按键盘操作
+
+
+
+ 菜单打开后按 ↑↓ / Enter 试试(当前 {{ open ? '已打开' : '已关闭' }})
+
+
+
diff --git a/code/g3soft-libs/docs/examples/dropdown/trigger.vue b/code/g3soft-libs/docs/examples/dropdown/trigger.vue
new file mode 100644
index 00000000..827d2c5f
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/dropdown/trigger.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+ hover 触发
+
+
+
+
+ 在此处右键
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/basic.vue b/code/g3soft-libs/docs/examples/form/basic.vue
new file mode 100644
index 00000000..602e39dd
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/basic.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+ 提交
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/grid.vue b/code/g3soft-libs/docs/examples/form/grid.vue
new file mode 100644
index 00000000..e1facfdb
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/grid.vue
@@ -0,0 +1,46 @@
+
+
+
+ {}"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/group.vue b/code/g3soft-libs/docs/examples/form/group.vue
new file mode 100644
index 00000000..e3caddf4
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/group.vue
@@ -0,0 +1,33 @@
+
+
+
+ {}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 连接
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/layout.vue b/code/g3soft-libs/docs/examples/form/layout.vue
new file mode 100644
index 00000000..829310b9
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/layout.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/rules.vue b/code/g3soft-libs/docs/examples/form/rules.vue
new file mode 100644
index 00000000..c0709706
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/rules.vue
@@ -0,0 +1,61 @@
+
+
+
+ ) => G3Message.success(JSON.stringify(values))"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 提交
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/submit.vue b/code/g3soft-libs/docs/examples/form/submit.vue
new file mode 100644
index 00000000..d62d3a85
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/submit.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ G3Message.success('全部通过,已提交')"
+ @submit-failed="(payload: { errorFields: unknown[] }) => G3Message.error(`失败:${payload.errorFields.length} 个字段`)"
+ @reset="G3Message.info('已重置')"
+ >
+
+
+
+
+
+
+
+
+ 提交
+ 重置
+ 手动校验
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/form/validate-trigger.vue b/code/g3soft-libs/docs/examples/form/validate-trigger.vue
new file mode 100644
index 00000000..2598803b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/form/validate-trigger.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/align.vue b/code/g3soft-libs/docs/examples/grid/align.vue
new file mode 100644
index 00000000..59c7b180
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/align.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+ span 6
+ span 6(更高)
+ span 6
+
+
+
+ 居中
+ 居中
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/basic.vue b/code/g3soft-libs/docs/examples/grid/basic.vue
new file mode 100644
index 00000000..4afa64b4
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/basic.vue
@@ -0,0 +1,25 @@
+
+
+
+
+ span 24
+ span 12
+ span 12
+ span 8
+ span 8
+ span 8
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/flex.vue b/code/g3soft-libs/docs/examples/grid/flex.vue
new file mode 100644
index 00000000..ed389293
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/flex.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 固定 200px
+ 自适应剩余空间
+
+
+
+ 1 份
+ 2 份
+ 1 份
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/gutter.vue b/code/g3soft-libs/docs/examples/grid/gutter.vue
new file mode 100644
index 00000000..92201bcc
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/gutter.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+ gutter 16
+ gutter 16
+ gutter 16
+
+
+
+ 水平 32
+ 水平 32
+ 水平 32
+ 垂直 12
+ 垂直 12
+ 垂直 12
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/no-wrap.vue b/code/g3soft-libs/docs/examples/grid/no-wrap.vue
new file mode 100644
index 00000000..96ef8f17
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/no-wrap.vue
@@ -0,0 +1,24 @@
+
+
+
+
+ span 8
+ span 8
+ span 8
+ 超出 24,但不换行
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/grid/offset.vue b/code/g3soft-libs/docs/examples/grid/offset.vue
new file mode 100644
index 00000000..e4987041
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/grid/offset.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+ span 8
+ span 8 / offset 8
+
+
+
+ push 6
+ pull 6
+
+
+
+ order 2
+ order 1
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/basic.vue b/code/g3soft-libs/docs/examples/input/basic.vue
new file mode 100644
index 00000000..6f136e3c
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
值:{{ value || '(空)' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/input/clearable.vue b/code/g3soft-libs/docs/examples/input/clearable.vue
new file mode 100644
index 00000000..c54c4d40
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/clearable.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/methods.vue b/code/g3soft-libs/docs/examples/input/methods.vue
new file mode 100644
index 00000000..cb98bf88
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/methods.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+ 聚焦并全选
+
+ 仅选中
+ 失焦
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/number.vue b/code/g3soft-libs/docs/examples/input/number.vue
new file mode 100644
index 00000000..495ab6da
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/number.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ 提交值:{{ amount }},类型:{{ typeof amount }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/password.vue b/code/g3soft-libs/docs/examples/input/password.vue
new file mode 100644
index 00000000..eee90bc9
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/password.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/slots.vue b/code/g3soft-libs/docs/examples/input/slots.vue
new file mode 100644
index 00000000..02cd31a9
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/slots.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ kg
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/status.vue b/code/g3soft-libs/docs/examples/input/status.vue
new file mode 100644
index 00000000..24a3e46d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/status.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/input/textarea.vue b/code/g3soft-libs/docs/examples/input/textarea.vue
new file mode 100644
index 00000000..2e6b3f2b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/input/textarea.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/layout/basic.vue b/code/g3soft-libs/docs/examples/layout/basic.vue
new file mode 100644
index 00000000..e2536868
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/layout/basic.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+ G3 FMS
+
+
+ admin
+
+
+ 当前路由:{{ activeKey }}
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/message/basic.vue b/code/g3soft-libs/docs/examples/message/basic.vue
new file mode 100644
index 00000000..2a5ec827
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/message/basic.vue
@@ -0,0 +1,13 @@
+
+
+
+
+ info
+ success
+ warning
+ error
+
+
diff --git a/code/g3soft-libs/docs/examples/message/options.vue b/code/g3soft-libs/docs/examples/message/options.vue
new file mode 100644
index 00000000..760f263a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/message/options.vue
@@ -0,0 +1,23 @@
+
+
+
+
+ 不自动关闭
+ 代码关闭
+
+ 自定义时长
+
+
+
diff --git a/code/g3soft-libs/docs/examples/message/placement.vue b/code/g3soft-libs/docs/examples/message/placement.vue
new file mode 100644
index 00000000..248e08da
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/message/placement.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+ {{ placement }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/basic.vue b/code/g3soft-libs/docs/examples/modal/basic.vue
new file mode 100644
index 00000000..da7e6f9f
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/basic.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+ 打开对话框
+
+
+
+
+
+
+ {{ log }}
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/confirm-async.vue b/code/g3soft-libs/docs/examples/modal/confirm-async.vue
new file mode 100644
index 00000000..e9534818
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/confirm-async.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ 异步成功
+ 异步失败
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/confirm.vue b/code/g3soft-libs/docs/examples/modal/confirm.vue
new file mode 100644
index 00000000..02c910f1
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/confirm.vue
@@ -0,0 +1,34 @@
+
+
+
+
+ 删除确认
+ 演示 destroy()
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/footer-slot.vue b/code/g3soft-libs/docs/examples/modal/footer-slot.vue
new file mode 100644
index 00000000..f2eaed67
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/footer-slot.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+ 自定义底部
+
+
+
+
+ 帮助
+
+
+ 底部可以放多个按钮、步骤指示器或协议勾选。
+
+
+
+ 第 1 / 3 步
+
+ 取消
+ 下一步
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/form.vue b/code/g3soft-libs/docs/examples/modal/form.vue
new file mode 100644
index 00000000..90980001
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/form.vue
@@ -0,0 +1,44 @@
+
+
+
+
+ 新建(表单弹窗)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/mask.vue b/code/g3soft-libs/docs/examples/modal/mask.vue
new file mode 100644
index 00000000..9dc19652
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/mask.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ 点击遮罩关闭
+ Esc 关闭
+ 显示关闭按钮
+ 显示底部按钮
+
+
+
+ 打开
+
+
+
+ 强制用户做出选择时,把 maskClosable / keyboard / closable 都设为 false。
+
+
+
diff --git a/code/g3soft-libs/docs/examples/modal/resize.vue b/code/g3soft-libs/docs/examples/modal/resize.vue
new file mode 100644
index 00000000..1d042fee
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/modal/resize.vue
@@ -0,0 +1,29 @@
+
+
+
+
+ 可拖动 / 缩放
+ 居中显示
+
+
+ 面板默认贴顶显示;开启 centered 后垂直居中,适合确认类与短表单。
+
+ 右上角的全屏按钮与拖动/缩放互斥:全屏时会自动复位位置。
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/notification/basic.vue b/code/g3soft-libs/docs/examples/notification/basic.vue
new file mode 100644
index 00000000..79f7c7f3
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/notification/basic.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 成功通知
+
+
+
+ 不自动关闭的错误通知
+
+
+
diff --git a/code/g3soft-libs/docs/examples/notification/close.vue b/code/g3soft-libs/docs/examples/notification/close.vue
new file mode 100644
index 00000000..fc22842d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/notification/close.vue
@@ -0,0 +1,23 @@
+
+
+
+
+ 打开多条
+ 句柄关闭
+ 全部清空
+
+
diff --git a/code/g3soft-libs/docs/examples/notification/placement.vue b/code/g3soft-libs/docs/examples/notification/placement.vue
new file mode 100644
index 00000000..53126098
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/notification/placement.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {{ placement }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/pagination/basic.vue b/code/g3soft-libs/docs/examples/pagination/basic.vue
new file mode 100644
index 00000000..709c2a15
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/pagination/basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
当前第 {{ current }} 页
+
+
diff --git a/code/g3soft-libs/docs/examples/pagination/hide-on-single.vue b/code/g3soft-libs/docs/examples/pagination/hide-on-single.vue
new file mode 100644
index 00000000..47060629
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/pagination/hide-on-single.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 只有一页(8 条)
+ 切到 86 条可以看完整分页
+
+
+ (current = page)"
+ />
+
+
diff --git a/code/g3soft-libs/docs/examples/pagination/quick-jumper.vue b/code/g3soft-libs/docs/examples/pagination/quick-jumper.vue
new file mode 100644
index 00000000..76ccba9c
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/pagination/quick-jumper.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/pagination/size-changer.vue b/code/g3soft-libs/docs/examples/pagination/size-changer.vue
new file mode 100644
index 00000000..c4158ce5
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/pagination/size-changer.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
(lastEvent = `切换每页 ${size} 条,当前第 ${page} 页`)"
+ />
+
+ 每页 {{ pageSize }} 条|{{ lastEvent }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/pagination/total.vue b/code/g3soft-libs/docs/examples/pagination/total.vue
new file mode 100644
index 00000000..6ad990fb
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/pagination/total.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+ 第 {{ from }}-{{ to }} 条 / 共 {{ total }} 条
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/popover/arrow-width.vue b/code/g3soft-libs/docs/examples/popover/arrow-width.vue
new file mode 100644
index 00000000..7fb3d94e
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/popover/arrow-width.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+ 无箭头
+ arrow=false 时没有尖角,适合菜单类浮层
+
+
+
+
+
+
+ 触发器(320px 宽)
+
+
+ 浮层宽度跟随触发器,内容更长时会自动变宽
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/popover/basic.vue b/code/g3soft-libs/docs/examples/popover/basic.vue
new file mode 100644
index 00000000..f92e5b23
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/popover/basic.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+ 点击查看
+
+
+
+ 浮层内容可以直接写 HTML,也可以放任意组件。
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/popover/controlled.vue b/code/g3soft-libs/docs/examples/popover/controlled.vue
new file mode 100644
index 00000000..6cdb6e7b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/popover/controlled.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 受控气泡
+
+
+
+
受控模式下,所有开关都经过 update:open。
+
知道了
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/popover/placement.vue b/code/g3soft-libs/docs/examples/popover/placement.vue
new file mode 100644
index 00000000..9044ba0d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/popover/placement.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+ 方位:{{ placement }}
+
+ 当前方位 {{ placement }},贴边时会自动翻到对面
+
+
+
diff --git a/code/g3soft-libs/docs/examples/popover/trigger.vue b/code/g3soft-libs/docs/examples/popover/trigger.vue
new file mode 100644
index 00000000..8c5d87d7
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/popover/trigger.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ hover 触发
+ 鼠标移入即显示,移出后延迟关闭
+
+
+
+ focus 触发
+ Tab 聚焦时显示,焦点移出关闭
+
+
+
+
+
+ 在此处右键
+
+
+ 在鼠标位置弹出,超出视口会自动回退
+
+
+
+
+
+ manual 触发
+
+
+ 手动关闭
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/radio/basic.vue b/code/g3soft-libs/docs/examples/radio/basic.vue
new file mode 100644
index 00000000..5f869245
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/radio/basic.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+ 按月付费
+ 按年付费(省 2 个月)
+ 一次性买断(暂不可选)
+
+
选中:{{ value }}
+
+
diff --git a/code/g3soft-libs/docs/examples/radio/cards.vue b/code/g3soft-libs/docs/examples/radio/cards.vue
new file mode 100644
index 00000000..a6693c9c
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/radio/cards.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ 基础版
+ 单项目 · 1GB 存储
+
+
+
+
+
+ 专业版
+ 多项目 · 100GB 存储
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/radio/vertical.vue b/code/g3soft-libs/docs/examples/radio/vertical.vue
new file mode 100644
index 00000000..e0052a40
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/radio/vertical.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ 紧凑视图
+ 标准视图
+ 宽松视图
+
+
+
+ 整组禁用 A
+ 整组禁用 B
+
+
+
diff --git a/code/g3soft-libs/docs/examples/scrollbar/autohide.vue b/code/g3soft-libs/docs/examples/scrollbar/autohide.vue
new file mode 100644
index 00000000..87364b3e
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/scrollbar/autohide.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
{{ mode }}
+
+
+ 第 {{ index }} 行
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/scrollbar/basic.vue b/code/g3soft-libs/docs/examples/scrollbar/basic.vue
new file mode 100644
index 00000000..23fe8aa2
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/scrollbar/basic.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 第 {{ index }} 行内容
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/basic.vue b/code/g3soft-libs/docs/examples/segmented/basic.vue
new file mode 100644
index 00000000..2dca70a7
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/block.vue b/code/g3soft-libs/docs/examples/segmented/block.vue
new file mode 100644
index 00000000..e4a10a19
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/block.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/custom-label.vue b/code/g3soft-libs/docs/examples/segmented/custom-label.vue
new file mode 100644
index 00000000..dfda8418
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/custom-label.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {{ (option as { label: string }).label }}
+
+ {{ (option as { count: number }).count }}
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/options.vue b/code/g3soft-libs/docs/examples/segmented/options.vue
new file mode 100644
index 00000000..d997262f
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/options.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/shape.vue b/code/g3soft-libs/docs/examples/segmented/shape.vue
new file mode 100644
index 00000000..d91fab6a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/shape.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/uncontrolled.vue b/code/g3soft-libs/docs/examples/segmented/uncontrolled.vue
new file mode 100644
index 00000000..907dd143
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/uncontrolled.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
change 收到:{{ current }}
+
+
diff --git a/code/g3soft-libs/docs/examples/segmented/vertical.vue b/code/g3soft-libs/docs/examples/segmented/vertical.vue
new file mode 100644
index 00000000..91db7859
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/segmented/vertical.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/basic.vue b/code/g3soft-libs/docs/examples/select/basic.vue
new file mode 100644
index 00000000..4d6084fd
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/basic.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
选中:{{ value ?? '(未选)' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/select/custom-option.vue b/code/g3soft-libs/docs/examples/select/custom-option.vue
new file mode 100644
index 00000000..84b0130a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/custom-option.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
{
+ select(size.value)
+ close()
+ }
+ "
+ >
+
+
+
+ {{ size.label }}
+ {{ size.desc }}
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/field-names.vue b/code/g3soft-libs/docs/examples/select/field-names.vue
new file mode 100644
index 00000000..571eff72
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/field-names.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/filterable.vue b/code/g3soft-libs/docs/examples/select/filterable.vue
new file mode 100644
index 00000000..79b7cb99
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/filterable.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/load-more.vue b/code/g3soft-libs/docs/examples/select/load-more.vue
new file mode 100644
index 00000000..78193c32
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/load-more.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+ {{ isLoading ? '加载中…' : `已加载 ${options.length} 条` }}
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/multiple.vue b/code/g3soft-libs/docs/examples/select/multiple.vue
new file mode 100644
index 00000000..324f9f4a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/multiple.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/select/remote.vue b/code/g3soft-libs/docs/examples/select/remote.vue
new file mode 100644
index 00000000..e03c54fc
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/select/remote.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/space/basic.vue b/code/g3soft-libs/docs/examples/space/basic.vue
new file mode 100644
index 00000000..6a6781f9
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/basic.vue
@@ -0,0 +1,12 @@
+
+
+
+
+ 按钮
+ 按钮
+ 文字
+
+
diff --git a/code/g3soft-libs/docs/examples/space/empty-children.vue b/code/g3soft-libs/docs/examples/space/empty-children.vue
new file mode 100644
index 00000000..f94b86ed
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/empty-children.vue
@@ -0,0 +1,19 @@
+
+
+
+
+ 显示「删除」按钮
+
+ 编辑
+ 复制
+
+ 删除
+
+
+
diff --git a/code/g3soft-libs/docs/examples/space/separator.vue b/code/g3soft-libs/docs/examples/space/separator.vue
new file mode 100644
index 00000000..36a1b16b
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/separator.vue
@@ -0,0 +1,16 @@
+
+
+
+
+ 编辑
+ 复制
+ 删除
+
+
+ |
+
+
+
diff --git a/code/g3soft-libs/docs/examples/space/size.vue b/code/g3soft-libs/docs/examples/space/size.vue
new file mode 100644
index 00000000..30c5708f
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/size.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+ small 8px
+ small 8px
+
+
+
+ middle 16px
+ middle 16px
+
+
+
+ large 24px
+ large 24px
+
+
+
+
+ 32px
+ 32px
+
+
+
diff --git a/code/g3soft-libs/docs/examples/space/vertical.vue b/code/g3soft-libs/docs/examples/space/vertical.vue
new file mode 100644
index 00000000..866122db
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/vertical.vue
@@ -0,0 +1,12 @@
+
+
+
+
+ 第一行
+ 第二行
+ 第三行
+
+
diff --git a/code/g3soft-libs/docs/examples/space/wrap.vue b/code/g3soft-libs/docs/examples/space/wrap.vue
new file mode 100644
index 00000000..6dc343d8
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/space/wrap.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ 按钮 {{ index }}
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/spin/basic.vue b/code/g3soft-libs/docs/examples/spin/basic.vue
new file mode 100644
index 00000000..20c8e148
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/spin/basic.vue
@@ -0,0 +1,25 @@
+
+
+
+
+ 重新加载
+
+
+
+ 被包裹的内容区域:加载中会被遮罩覆盖,并阻止点击。
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/spin/delay.vue b/code/g3soft-libs/docs/examples/spin/delay.vue
new file mode 100644
index 00000000..a2179788
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/spin/delay.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ 200ms 请求
+ 1500ms 请求
+
+
+
+ 短请求:因为 delay=400ms,全程不会闪出加载态
+
+
+
+ 长请求:超过 400ms 后才显示加载态
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/spin/standalone.vue b/code/g3soft-libs/docs/examples/spin/standalone.vue
new file mode 100644
index 00000000..470fca4d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/spin/standalone.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/splitter/basic.vue b/code/g3soft-libs/docs/examples/splitter/basic.vue
new file mode 100644
index 00000000..f6d3c8e1
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/splitter/basic.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
(lastSizes = sizes)"
+ >
+
+ 左侧 240px(可拖拽调整)
+
+
+ 右侧自动填充
+
+
+
+
+ 面板宽度:{{ lastSizes.join(' / ') || '(拖拽后显示)' }}
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/splitter/constraints.vue b/code/g3soft-libs/docs/examples/splitter/constraints.vue
new file mode 100644
index 00000000..6cd2898a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/splitter/constraints.vue
@@ -0,0 +1,33 @@
+
+
+
+
+ 左侧不可拖动(resizable=false)
+
+
+
+ 左侧:最小 160px,最大 50%
+
+
+ 右侧:最小 200px
+
+
+
+
+ 拖到边界会被钳制住;键盘用户可 Tab 聚焦分割线后用 ← → 调整(Shift 加速)
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/splitter/nested.vue b/code/g3soft-libs/docs/examples/splitter/nested.vue
new file mode 100644
index 00000000..5c1f96a3
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/splitter/nested.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+ 侧栏(200px)
+
+
+
+
+
+ 编辑区
+
+
+ 输出 / 控制台(120px)
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/splitter/vertical.vue b/code/g3soft-libs/docs/examples/splitter/vertical.vue
new file mode 100644
index 00000000..350ad5bd
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/splitter/vertical.vue
@@ -0,0 +1,34 @@
+
+
+
+
+ 分割线仅在 hover / 拖拽时显示
+
+
+
+ 上:120px
+
+
+ 下:自动填充
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/switch/basic.vue b/code/g3soft-libs/docs/examples/switch/basic.vue
new file mode 100644
index 00000000..40ee38bd
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/switch/basic.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
启用通知
+
状态:{{ enabled ? '开' : '关' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/switch/disabled.vue b/code/g3soft-libs/docs/examples/switch/disabled.vue
new file mode 100644
index 00000000..d0d24b12
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/switch/disabled.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ 禁用且带文字
+
+
diff --git a/code/g3soft-libs/docs/examples/switch/in-form.vue b/code/g3soft-libs/docs/examples/switch/in-form.vue
new file mode 100644
index 00000000..806bf421
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/switch/in-form.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tabs/basic.vue b/code/g3soft-libs/docs/examples/tabs/basic.vue
new file mode 100644
index 00000000..ebd87da1
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tabs/basic.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ 基本信息面板内容
+ 配置面板内容
+ 禁用项不可切换
+
+
diff --git a/code/g3soft-libs/docs/examples/tabs/items.vue b/code/g3soft-libs/docs/examples/tabs/items.vue
new file mode 100644
index 00000000..9139eb08
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tabs/items.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tabs/nav-only.vue b/code/g3soft-libs/docs/examples/tabs/nav-only.vue
new file mode 100644
index 00000000..007e48f2
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tabs/nav-only.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+ 项目:
+
+
+
+ {{ item === 'a' ? '标签 A(自定义)' : '标签 B' }}
+
+
+
+ + 新建
+
+
+
+
+ 当前激活:{{ active }}(内容由外部渲染)
+
+
diff --git a/code/g3soft-libs/docs/examples/tabs/placement.vue b/code/g3soft-libs/docs/examples/tabs/placement.vue
new file mode 100644
index 00000000..436b0283
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tabs/placement.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ 概览内容
+ 成员内容
+ 设置内容
+
+
diff --git a/code/g3soft-libs/docs/examples/tabs/type.vue b/code/g3soft-libs/docs/examples/tabs/type.vue
new file mode 100644
index 00000000..1264d936
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tabs/type.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tag/closable.vue b/code/g3soft-libs/docs/examples/tag/closable.vue
new file mode 100644
index 00000000..a474ee1e
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tag/closable.vue
@@ -0,0 +1,22 @@
+
+
+
+
+ item !== tag)"
+ >
+ {{ tag }}
+
+ 已全部移除
+
+
diff --git a/code/g3soft-libs/docs/examples/tag/icon.vue b/code/g3soft-libs/docs/examples/tag/icon.vue
new file mode 100644
index 00000000..b24cab9d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tag/icon.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ 已完成
+
+
+
+
+ 等待中
+
+
+
+
+ 张三
+ ×
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tag/link.vue b/code/g3soft-libs/docs/examples/tag/link.vue
new file mode 100644
index 00000000..f6669475
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tag/link.vue
@@ -0,0 +1,11 @@
+
+
+
+
+ 可点击标签
+ 禁用标签
+
+
diff --git a/code/g3soft-libs/docs/examples/tag/prevent-close.vue b/code/g3soft-libs/docs/examples/tag/prevent-close.vue
new file mode 100644
index 00000000..9bc903f5
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tag/prevent-close.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {{ visible ? '受控标签' : '已关闭' }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tag/variant.vue b/code/g3soft-libs/docs/examples/tag/variant.vue
new file mode 100644
index 00000000..9f920553
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tag/variant.vue
@@ -0,0 +1,15 @@
+
+
+
+
+ 默认
+ 次要
+ 描边
+ 已完成
+ 失败
+ 幽灵
+
+
diff --git a/code/g3soft-libs/docs/examples/tooltip/basic.vue b/code/g3soft-libs/docs/examples/tooltip/basic.vue
new file mode 100644
index 00000000..8acb8b18
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tooltip/basic.vue
@@ -0,0 +1,10 @@
+
+
+
+
+ 悬停查看提示
+
+
diff --git a/code/g3soft-libs/docs/examples/tooltip/disabled.vue b/code/g3soft-libs/docs/examples/tooltip/disabled.vue
new file mode 100644
index 00000000..409b378f
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tooltip/disabled.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ 启用提示
+
+ 悬停试试
+
+
+
+
+ 受控显示
+
+ 受控提示
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tooltip/placement.vue b/code/g3soft-libs/docs/examples/tooltip/placement.vue
new file mode 100644
index 00000000..fe4bf382
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tooltip/placement.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+ {{ placement }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tooltip/rich.vue b/code/g3soft-libs/docs/examples/tooltip/rich.vue
new file mode 100644
index 00000000..40960c5a
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tooltip/rich.vue
@@ -0,0 +1,20 @@
+
+
+
+
+ 富内容提示
+
+
+
+ 发布到生产环境
+
+ 会触发全量构建与灰度发布,平均耗时 3 分钟。快捷键
+ ⌘ + Shift + P
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/basic.vue b/code/g3soft-libs/docs/examples/tree/basic.vue
new file mode 100644
index 00000000..b3d268ee
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/basic.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
选中:{{ selectedKeys.join(', ') || '无' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/checkable.vue b/code/g3soft-libs/docs/examples/tree/checkable.vue
new file mode 100644
index 00000000..8ebe92d7
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/checkable.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
父子联动(半选会回传)
+
+
checked: {{ linked }}
+
+
+
+
checkStrictly(互不影响)
+
+
checked: {{ strict }}
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/context-menu.vue b/code/g3soft-libs/docs/examples/tree/context-menu.vue
new file mode 100644
index 00000000..4885d603
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/context-menu.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/custom.vue b/code/g3soft-libs/docs/examples/tree/custom.vue
new file mode 100644
index 00000000..74dba292
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/custom.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+ {{ node.name }}
+ 当前
+
+ 选中
+
+
+
+
+
+
选中:{{ selectedKeys.join(', ') || '无' }}
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/draggable.vue b/code/g3soft-libs/docs/examples/tree/draggable.vue
new file mode 100644
index 00000000..727098c7
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/draggable.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
拖拽任意节点试试(子节点不能拖进自己的子树)
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/expand.vue b/code/g3soft-libs/docs/examples/tree/expand.vue
new file mode 100644
index 00000000..f5e214ef
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/expand.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ 全部展开
+ 全部收起
+
+
+
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/filter.vue b/code/g3soft-libs/docs/examples/tree/filter.vue
new file mode 100644
index 00000000..e650e24d
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/filter.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ 没有匹配的节点
+
+
+
diff --git a/code/g3soft-libs/docs/examples/tree/lazy.vue b/code/g3soft-libs/docs/examples/tree/lazy.vue
new file mode 100644
index 00000000..292dfade
--- /dev/null
+++ b/code/g3soft-libs/docs/examples/tree/lazy.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
已加载:{{ loadedKeys.join(', ') || '无' }}
+
+
diff --git a/code/g3soft-libs/docs/nuxt.config.ts b/code/g3soft-libs/docs/nuxt.config.ts
index a9db24e5..e3b689f5 100644
--- a/code/g3soft-libs/docs/nuxt.config.ts
+++ b/code/g3soft-libs/docs/nuxt.config.ts
@@ -35,6 +35,13 @@ export default defineNuxtConfig({
find: /^@g3soft\/ui$/,
replacement: fileURLToPath(new URL('../packages/ui/src/index.ts', import.meta.url)),
},
+ {
+ // framework 也直连源码:改布局 / 偏好即时热更
+ find: /^@g3soft\/framework$/,
+ replacement: fileURLToPath(
+ new URL('../packages/framework/src/index.ts', import.meta.url),
+ ),
+ },
{
// tokens 是 SCSS 编译产物:仅裸导入指向 dist/index.css(由 @g3soft/tokens 的 build 生成)
find: /^@g3soft\/tokens$/,
diff --git a/code/g3soft-libs/package.json b/code/g3soft-libs/package.json
index 5061bdc9..d616ea74 100644
--- a/code/g3soft-libs/package.json
+++ b/code/g3soft-libs/package.json
@@ -12,14 +12,16 @@
"build:docs": "pnpm --filter @g3soft/tokens build && pnpm --filter @g3soft/docs generate",
"preview:docs": "pnpm --filter @g3soft/docs preview",
"build": "pnpm -r --filter \"./packages/**\" build",
- "gen:api": "node scripts/gen-api.mjs",
"changeset": "changeset",
"version": "changeset version",
- "release": "pnpm build && changeset publish"
+ "release": "pnpm build && changeset publish",
+ "playground": "pnpm --filter @g3soft/ui playground",
+ "test": "pnpm --filter @g3soft/ui test:unit",
+ "test:e2e": "pnpm --filter @g3soft/ui test:e2e",
+ "verify": "pnpm --filter @g3soft/tokens build && pnpm --filter @g3soft/ui verify"
},
"devDependencies": {
"@changesets/cli": "^2.29.0",
- "typescript": "catalog:",
- "vue-docgen-api": "^4.79.0"
+ "typescript": "catalog:"
}
}
diff --git a/code/g3soft-libs/packages/framework/README.md b/code/g3soft-libs/packages/framework/README.md
new file mode 100644
index 00000000..d548af9a
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/README.md
@@ -0,0 +1,83 @@
+# @g3soft/framework
+
+G3Soft 前端框架。目标:**基础架子一处维护,留好插槽和接口,业务项目引用方便,既不绑死又灵活。**
+
+## 设计原则
+
+> framework 提供「骨架 + 契约」,业务负责「填充」;依赖单向 `host → framework`,framework 永不 import 业务代码。
+
+- **三层契约**:配置契约(`G3FrameworkOptions`)/ 数据契约(`NavItem`、`G3RouteMeta`、`UserInfo`)/ UI 契约(slots + emits)。
+- **一切外部信息靠注入**:菜单、用户、token、跳转都由宿主提供,framework 内部零业务依赖。
+- **默认能跑**:零配置即可启动(默认 `sidebar-nav` + 浅色),配置与插槽均为可选增强。
+
+## 归属边界
+
+| 层 | 内容 | 说明 |
+|---|---|---|
+| `core` | 布局(7 种模式)、偏好模型、契约类型 | 后端无关,只依赖 `vue`(peer) + `@g3soft/ui` |
+| `presets/*` | 传输、`ApiResponse` 解包、`/data/*` client、默认菜单源 | **后续接入**,可选,import 才生效 |
+| 项目 | 业务接口、业务页面 | —— |
+
+**铁律**:`core` 里绝不出现具体后端名(如 `s_menu`、`/data/loaddata`、`ApiResponse`);一旦出现,它就该在 `presets`。
+
+## 当前进度
+
+- [x] core 类型契约(数据 / 配置 / UI 三类)
+- [x] 偏好层:`defaultPreferences` / `usePreferences`(reactive 单例,无需 pinia)/ 持久化 / `applyThemeVars`
+- [x] `createG3Framework`(`app.use()` 一次初始化)
+- [x] 布局子组件:Logo / 递归 Menu(垂直+水平)/ Sidebar(单列+双列,含折叠按钮、拖拽调宽、悬浮展开)/ Header / Breadcrumb / Tabbar
+- [x] `G3Layout` 编排 7 种模式(模式配置表驱动)
+- [ ] 偏好抽屉面板
+- [ ] 接线层 `G3AdminLayout`(接 router / 页签状态 / 用户菜单)
+- [ ] 接口层 preset(`/request` + g3-backend + 默认菜单源)
+
+布局结构与交互对齐 [vben-admin](https://github.com/vbenjs/vue-vben-admin) 的 `layout-ui`,样式改写为 `--g3-*` 变量 + `g3-` 前缀(**不引入 Tailwind**,业务项目零额外配置)。
+
+## 使用
+
+```ts
+// main.ts —— 初始化(可选)
+import { createG3Framework } from '@g3soft/framework'
+app.use(
+ createG3Framework({
+ preferences: { app: { layout: 'sidebar-nav' }, theme: { colorPrimary: '#2563eb' } },
+ storageKey: 'g3-preferences',
+ }),
+)
+```
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+菜单数据由宿主注入(`:menus` 直接给数组,或 `:menu-source` 给函数),framework 不关心来源。
+
+## 偏好与主题
+
+- `usePreferences()`:读偏好(只读响应式)、`update(patch)` / `setLayout()` / `toggleSidebar()`。
+- 偏好状态是**模块级 reactive 单例**,不依赖 pinia;持久化用 `localStorage`(SSR 安全)。
+- 主题唯一输出仍是 `@g3soft/ui` 的 `ConfigProvider → derivePalette → --g3-*`;`radius` / `fontSize` / 布局尺寸写入 CSS 变量。
+
+## 归属边界
+
+| 层 | 内容 | 说明 |
+|---|---|---|
+| `core` | 布局(7 种模式)、偏好、契约类型 | 后端无关,只依赖 `vue`(peer) + `@g3soft/ui` |
+| `presets/*` | 传输、`ApiResponse` 解包、`/data/*` client、默认菜单源 | **后续接入**,可选,import 才生效 |
+| 项目 | 业务接口、业务页面 | —— |
+
+**铁律**:`core` 里绝不出现具体后端名(如 `s_menu`、`/data/loaddata`、`ApiResponse`);一旦出现,它就该在 `presets`。
diff --git a/code/g3soft-libs/packages/framework/package.json b/code/g3soft-libs/packages/framework/package.json
new file mode 100644
index 00000000..db63acb7
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@g3soft/framework",
+ "version": "0.0.1",
+ "description": "G3Soft 前端框架:布局骨架 + 偏好设置,供业务项目引用(core 先行,接口层后续以 preset 形式接入)",
+ "license": "UNLICENSED",
+ "type": "module",
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./core": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./style.css": "./dist/style.css",
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "build": "vite build",
+ "prepublishOnly": "publint"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ },
+ "dependencies": {
+ "@g3soft/ui": "workspace:*"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.0",
+ "publint": "latest",
+ "vite": "^8.0.0",
+ "vite-plugin-dts": "latest",
+ "vue": "catalog:",
+ "vue-tsc": "^3.0.0"
+ },
+ "publishConfig": {
+ "access": "restricted"
+ }
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/_utils/deep-merge.ts b/code/g3soft-libs/packages/framework/src/core/_utils/deep-merge.ts
new file mode 100644
index 00000000..6ad1f5d1
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/_utils/deep-merge.ts
@@ -0,0 +1,38 @@
+/** 是否为「普通对象」(排除数组 / null) */
+export function isPlainObject(value: unknown): value is Record
{
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * 深合并:把 source 递归覆盖到 target(就地修改并返回 target)。
+ * undefined 视为「未提供」,不覆盖;数组整体替换。
+ */
+export function deepMerge>(
+ target: T,
+ source?: Partial | Record,
+): T {
+ if (!isPlainObject(source)) return target
+ for (const key of Object.keys(source)) {
+ const next = (source as Record)[key]
+ if (next === undefined) continue
+ const current = (target as Record)[key]
+ if (isPlainObject(next) && isPlainObject(current)) {
+ deepMerge(current, next)
+ } else {
+ ;(target as Record)[key] = next
+ }
+ }
+ return target
+}
+
+/** 深克隆(结构化克隆优先,退回 JSON) */
+export function deepClone(value: T): T {
+ if (typeof structuredClone === 'function') {
+ try {
+ return structuredClone(value)
+ } catch {
+ /* 含不可克隆值时退回 JSON */
+ }
+ }
+ return JSON.parse(JSON.stringify(value)) as T
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/index.ts b/code/g3soft-libs/packages/framework/src/core/index.ts
new file mode 100644
index 00000000..e5a23f8d
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/index.ts
@@ -0,0 +1,75 @@
+// core:布局骨架 + 偏好 + 契约类型。后端无关,依赖仅 vue(peer) + @g3soft/ui。
+// 接口层(request / ApiResponse 解包 / 默认菜单源)不在 core,后续以 preset 形式接入。
+
+// —— 布局 ——
+export {
+ G3Layout,
+ G3LayoutBreadcrumb,
+ G3LayoutHeader,
+ G3LayoutLogo,
+ G3LayoutMenu,
+ G3LayoutSidebar,
+ G3LayoutTabbar,
+ layoutContextKey,
+ layoutLabels,
+ layoutModes,
+ useLayoutContext,
+} from './layout'
+export type { LayoutContext, LayoutModeConfig } from './layout'
+
+// —— 偏好 ——
+export {
+ DEFAULT_STORAGE_KEY,
+ applyDataTheme,
+ applyThemeVars,
+ builtinPalettes,
+ defaultPreferences,
+ defaultSizes,
+ resolveDark,
+ setupPreferences,
+ toCssLength,
+ usePreferences,
+} from './preferences'
+export type { G3PreferencesApi } from './preferences'
+
+// —— 安装 ——
+export { createG3Framework } from './install'
+export type { G3Framework } from './install'
+
+// —— 数据契约 ——
+export type {
+ G3LayoutProps,
+ G3RouteMeta,
+ G3TabItem,
+ LayoutComponentOverrides,
+ LayoutSizes,
+ LayoutType,
+ MenuSource,
+ NavItem,
+ UserInfo,
+} from './types/layout'
+
+// —— 配置契约 ——
+export type {
+ AppPreferences,
+ BreadcrumbPreferences,
+ BreadcrumbStyleType,
+ ContentPreferences,
+ DeepPartial,
+ FooterPreferences,
+ G3FrameworkOptions,
+ G3Preferences,
+ HeaderMode,
+ HeaderPreferences,
+ LogoPreferences,
+ NavigationPreferences,
+ NavigationStyleType,
+ SidebarPreferences,
+ TabbarPreferences,
+ TabbarStyleType,
+ ThemeMode,
+ ThemePreferences,
+} from './types/preferences'
+
+// —— UI 契约 ——
+export type { G3LayoutEmits, G3LayoutSlots } from './types/slots'
diff --git a/code/g3soft-libs/packages/framework/src/core/install.ts b/code/g3soft-libs/packages/framework/src/core/install.ts
new file mode 100644
index 00000000..9fe3d1c6
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/install.ts
@@ -0,0 +1,39 @@
+import type { App } from 'vue'
+import type { G3FrameworkOptions } from './types/preferences'
+import { setupPreferences, usePreferences } from './preferences'
+import type { G3PreferencesApi } from './preferences'
+
+/** createG3Framework 的返回值:兼容 `app.use()` 协议,同时暴露偏好 API */
+export interface G3Framework {
+ install(app: App): void
+ readonly preferences: G3PreferencesApi
+}
+
+/**
+ * 初始化 G3 框架。宿主在 `main.ts` 里调用一次,其余按需 import。
+ *
+ * ```ts
+ * const g3 = createG3Framework({ preferences: { app: { layout: 'sidebar-nav' } } })
+ * app.use(g3)
+ * ```
+ *
+ * 说明:偏好为模块级单例,初始化在调用时即完成;`install` 仅用于满足 `app.use()` 协议。
+ */
+export function createG3Framework(options: G3FrameworkOptions = {}): G3Framework {
+ setupPreferences({
+ preferences: options.preferences,
+ sizes: options.sizes,
+ theme: options.theme,
+ storageKey: options.storageKey,
+ persist: options.persist,
+ })
+
+ return {
+ install() {
+ // no-op:单例已在 setupPreferences 中生效
+ },
+ get preferences() {
+ return usePreferences()
+ },
+ }
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3Layout.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3Layout.vue
new file mode 100644
index 00000000..3e5432fa
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3Layout.vue
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutBreadcrumb.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutBreadcrumb.vue
new file mode 100644
index 00000000..10688b45
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutBreadcrumb.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutHeader.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutHeader.vue
new file mode 100644
index 00000000..99590af2
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutHeader.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutLogo.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutLogo.vue
new file mode 100644
index 00000000..c77ef531
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutLogo.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
![logo]()
+
G3
+
{{ text }}
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutMenu.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutMenu.vue
new file mode 100644
index 00000000..8f778b50
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutMenu.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutSidebar.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutSidebar.vue
new file mode 100644
index 00000000..92d33b30
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutSidebar.vue
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutTabbar.vue b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutTabbar.vue
new file mode 100644
index 00000000..5c1fae25
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/G3LayoutTabbar.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/context.ts b/code/g3soft-libs/packages/framework/src/core/layout/context.ts
new file mode 100644
index 00000000..cba5e5ef
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/context.ts
@@ -0,0 +1,32 @@
+import type { ComputedRef, InjectionKey } from 'vue'
+import { inject } from 'vue'
+import type { LayoutType, NavItem, UserInfo } from '../types/layout'
+
+/** G3Layout 向子树提供的上下文(避免层层 props 传递) */
+export interface LayoutContext {
+ /** 生效的布局模式 */
+ layout: ComputedRef
+ /** 侧栏折叠 */
+ collapsed: ComputedRef
+ /** 当前激活 key */
+ activeKey: ComputedRef
+ /** 菜单数据 */
+ menus: ComputedRef
+ /** 当前用户 */
+ user: ComputedRef
+ /** 切换侧栏折叠 */
+ toggleCollapsed: () => void
+ /** 菜单导航(冒泡为 navigate 事件) */
+ navigate: (item: NavItem, event?: MouseEvent) => void
+}
+
+export const layoutContextKey: InjectionKey = Symbol('g3-layout-context')
+
+/** 在 G3Layout 子树内获取布局上下文 */
+export function useLayoutContext(): LayoutContext {
+ const ctx = inject(layoutContextKey, null)
+ if (!ctx) {
+ throw new Error('[g3] useLayoutContext() 必须在 内使用')
+ }
+ return ctx
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/index.ts b/code/g3soft-libs/packages/framework/src/core/layout/index.ts
new file mode 100644
index 00000000..f06daaa3
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/index.ts
@@ -0,0 +1,13 @@
+export { default as G3Layout } from './G3Layout.vue'
+export { default as G3LayoutSidebar } from './G3LayoutSidebar.vue'
+export { default as G3LayoutHeader } from './G3LayoutHeader.vue'
+export { default as G3LayoutMenu } from './G3LayoutMenu.vue'
+export { default as G3LayoutTabbar } from './G3LayoutTabbar.vue'
+export { default as G3LayoutBreadcrumb } from './G3LayoutBreadcrumb.vue'
+export { default as G3LayoutLogo } from './G3LayoutLogo.vue'
+
+export { layoutLabels, layoutModes } from './modes'
+export type { LayoutModeConfig } from './modes'
+
+export { layoutContextKey, useLayoutContext } from './context'
+export type { LayoutContext } from './context'
diff --git a/code/g3soft-libs/packages/framework/src/core/layout/modes.ts b/code/g3soft-libs/packages/framework/src/core/layout/modes.ts
new file mode 100644
index 00000000..a77cc32a
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/layout/modes.ts
@@ -0,0 +1,96 @@
+import type { LayoutType } from '../types/layout'
+
+/**
+ * 布局模式配置表 —— 7 种布局由「一个 G3Layout + 这张表」驱动,不写 7 个组件。
+ */
+export interface LayoutModeConfig {
+ /** 侧栏:false 无 / 'single' 单列 / 'mixed' 双列(左窄一级 + 右列子级) */
+ sidebar: false | 'single' | 'mixed'
+ /** 是否显示顶栏 */
+ header: boolean
+ /** 菜单位置:侧栏 / 顶栏 / 两者 */
+ menuPlacement: 'sidebar' | 'header' | 'both'
+ /** 顶栏是否横跨整宽(false = 顶栏位于侧栏右侧,不与侧栏并排) */
+ headerFullWidth: boolean
+ /** 顶栏下方是否再有「二级菜单行」 */
+ subMenuRow: boolean
+ /** 是否显示页签栏 */
+ tabbar: boolean
+}
+
+export const layoutModes: Record = {
+ /** 垂直侧边菜单 */
+ 'sidebar-nav': {
+ sidebar: 'single',
+ header: true,
+ menuPlacement: 'sidebar',
+ headerFullWidth: false,
+ subMenuRow: false,
+ tabbar: true,
+ },
+ /** 顶部水平菜单 */
+ 'header-nav': {
+ sidebar: false,
+ header: true,
+ menuPlacement: 'header',
+ headerFullWidth: true,
+ subMenuRow: false,
+ tabbar: true,
+ },
+ /** 双列菜单 */
+ 'sidebar-mixed-nav': {
+ sidebar: 'mixed',
+ header: true,
+ menuPlacement: 'sidebar',
+ headerFullWidth: false,
+ subMenuRow: false,
+ tabbar: true,
+ },
+ /** 无侧栏无顶栏 */
+ 'full-content': {
+ sidebar: false,
+ header: false,
+ menuPlacement: 'sidebar',
+ headerFullWidth: false,
+ subMenuRow: false,
+ tabbar: false,
+ },
+ /** 顶部一级 + 侧栏二级 */
+ 'mixed-nav': {
+ sidebar: 'single',
+ header: true,
+ menuPlacement: 'both',
+ headerFullWidth: false,
+ subMenuRow: false,
+ tabbar: true,
+ },
+ /** 顶部混合:顶栏 + 二级菜单行 */
+ 'header-mixed-nav': {
+ sidebar: false,
+ header: true,
+ menuPlacement: 'header',
+ headerFullWidth: true,
+ subMenuRow: true,
+ tabbar: true,
+ },
+ /** 顶栏横跨 + 侧栏 */
+ 'header-sidebar-nav': {
+ sidebar: 'single',
+ header: true,
+ menuPlacement: 'both',
+ headerFullWidth: true,
+ subMenuRow: false,
+ tabbar: true,
+ },
+}
+
+/** 布局模式展示名(偏好抽屉用) */
+export const layoutLabels: Record = {
+ 'sidebar-nav': '垂直菜单',
+ 'header-nav': '顶部菜单',
+ 'sidebar-mixed-nav': '双列菜单',
+ 'full-content': '通栏内容',
+ 'mixed-nav': '顶 + 侧混合',
+ 'header-mixed-nav': '顶部混合',
+ 'header-sidebar-nav': '顶栏 + 侧栏',
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/preferences/apply.ts b/code/g3soft-libs/packages/framework/src/core/preferences/apply.ts
new file mode 100644
index 00000000..e41dd17c
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/preferences/apply.ts
@@ -0,0 +1,52 @@
+import type { LayoutSizes } from '../types/layout'
+import type { ThemeMode, ThemePreferences } from '../types/preferences'
+
+/** 数字按 px 处理,字符串原样返回 */
+export function toCssLength(value: string | number): string {
+ return typeof value === 'number' ? `${value}px` : value
+}
+
+/**
+ * 主题模式 → 是否暗色。`auto` 时读取系统偏好(SSR 下安全回退为 false)。
+ */
+export function resolveDark(mode: ThemeMode): boolean {
+ if (mode === 'auto') {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ }
+ return mode === 'dark'
+}
+
+/**
+ * 把偏好中的「非颜色」部分写到根元素 CSS 变量。
+ * 颜色 / 暗色交给 @g3soft/ui 的 ConfigProvider(derivePalette),此处不重复处理颜色。
+ */
+export function applyThemeVars(
+ theme: ThemePreferences,
+ sizes: LayoutSizes,
+ root?: HTMLElement,
+): void {
+ if (typeof document === 'undefined') return
+ const el = root ?? document.documentElement
+
+ if (theme.radius != null) el.style.setProperty('--g3-radius', toCssLength(theme.radius))
+ if (theme.fontSize != null) el.style.setProperty('--g3-font-size-base', toCssLength(theme.fontSize))
+
+ // 布局尺寸(供布局组件消费,避免组件里硬编码)
+ el.style.setProperty('--g3-layout-sidebar-width', toCssLength(sizes.sidebarWidth))
+ el.style.setProperty('--g3-layout-sidebar-collapse-width', toCssLength(sizes.sidebarCollapseWidth))
+ el.style.setProperty('--g3-layout-sidebar-max-width', toCssLength(sizes.sidebarMaxWidth))
+ el.style.setProperty('--g3-layout-mixed-width', toCssLength(sizes.mixedSidebarWidth))
+ el.style.setProperty('--g3-layout-header-height', toCssLength(sizes.headerHeight))
+ el.style.setProperty('--g3-layout-tabbar-height', toCssLength(sizes.tabbarHeight))
+}
+
+/**
+ * 切换 @g3soft/tokens 的暗色基座(`html[data-g3-theme='dark']`)。
+ * 注意:这只是 token 基座开关,颜色体系仍由 ConfigProvider 派生,二者不冲突。
+ */
+export function applyDataTheme(dark: boolean, root?: HTMLElement): void {
+ if (typeof document === 'undefined') return
+ const el = root ?? document.documentElement
+ el.dataset.g3Theme = dark ? 'dark' : 'light'
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/preferences/defaults.ts b/code/g3soft-libs/packages/framework/src/core/preferences/defaults.ts
new file mode 100644
index 00000000..56e2a913
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/preferences/defaults.ts
@@ -0,0 +1,104 @@
+import type { G3Preferences } from '../types/preferences'
+import type { LayoutSizes } from '../types/layout'
+
+/** 布局尺寸默认值:沿用 fms 现状(展开 220 / 折叠 48 / 最大 400;双列主列 80)。 */
+export const defaultSizes: LayoutSizes = {
+ sidebarWidth: 220,
+ sidebarCollapseWidth: 48,
+ sidebarMaxWidth: 400,
+ mixedSidebarWidth: 80,
+ headerHeight: 56,
+ tabbarHeight: 40,
+}
+
+/**
+ * 偏好默认值。零配置即以此启动。
+ * 主题唯一「输出」仍是 `ConfigProvider → derivePalette → --g3-*`,这里只存种子。
+ */
+export const defaultPreferences: G3Preferences = {
+ app: {
+ layout: 'sidebar-nav',
+ name: undefined,
+ locale: 'zh-CN',
+ defaultHomePath: '/',
+ isMobile: false,
+ },
+ theme: {
+ mode: 'light',
+ colorPrimary: '#18181b',
+ radius: '6px',
+ fontSize: '14px',
+ builtinType: undefined,
+ semiDarkSidebar: false,
+ semiDarkHeader: false,
+ semiDarkSubSidebar: false,
+ },
+ sidebar: {
+ width: defaultSizes.sidebarWidth,
+ collapseWidth: defaultSizes.sidebarCollapseWidth,
+ mixedWidth: defaultSizes.mixedSidebarWidth,
+ collapsed: false,
+ collapsedShowTitle: true,
+ expandOnHover: false,
+ draggable: true,
+ hidden: false,
+ },
+ header: {
+ enable: true,
+ height: defaultSizes.headerHeight,
+ hidden: false,
+ mode: 'fixed',
+ },
+ tabbar: {
+ enable: true,
+ styleType: 'chrome',
+ showIcon: true,
+ showRefresh: true,
+ showMore: true,
+ showMaximize: false,
+ keepAlive: true,
+ draggable: false,
+ persist: true,
+ maxCount: 20,
+ middleClickToClose: true,
+ },
+ navigation: {
+ styleType: 'rounded',
+ accordion: true,
+ split: false,
+ },
+ breadcrumb: {
+ enable: true,
+ showIcon: true,
+ showHome: true,
+ styleType: 'normal',
+ },
+ logo: {
+ enable: true,
+ source: undefined,
+ text: undefined,
+ },
+ footer: {
+ enable: false,
+ text: undefined,
+ },
+ content: {
+ compact: false,
+ padding: '16px',
+ transition: 'fade-slide',
+ },
+}
+
+/** 默认持久化 storage key */
+export const DEFAULT_STORAGE_KEY = 'g3-preferences'
+
+/** 预设色板:选中即写入 theme.colorPrimary(种子色) */
+export const builtinPalettes: { label: string; value: string }[] = [
+ { label: '石墨黑', value: '#18181b' },
+ { label: '科技蓝', value: '#2563eb' },
+ { label: '青碧', value: '#0891b2' },
+ { label: '翠绿', value: '#16a34a' },
+ { label: '琥珀', value: '#d97706' },
+ { label: '朱红', value: '#dc2626' },
+ { label: '紫罗兰', value: '#7c3aed' },
+]
diff --git a/code/g3soft-libs/packages/framework/src/core/preferences/index.ts b/code/g3soft-libs/packages/framework/src/core/preferences/index.ts
new file mode 100644
index 00000000..86b28fac
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/preferences/index.ts
@@ -0,0 +1,4 @@
+export { DEFAULT_STORAGE_KEY, builtinPalettes, defaultPreferences, defaultSizes } from './defaults'
+export { applyDataTheme, applyThemeVars, resolveDark, toCssLength } from './apply'
+export { setupPreferences, usePreferences } from './store'
+export type { G3PreferencesApi } from './store'
diff --git a/code/g3soft-libs/packages/framework/src/core/preferences/store.ts b/code/g3soft-libs/packages/framework/src/core/preferences/store.ts
new file mode 100644
index 00000000..40e01f00
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/preferences/store.ts
@@ -0,0 +1,185 @@
+import { computed, reactive, readonly, watch } from 'vue'
+import type { G3ThemeConfig } from '@g3soft/ui'
+import type { LayoutSizes, LayoutType } from '../types/layout'
+import type { DeepPartial, G3Preferences } from '../types/preferences'
+import { deepClone, deepMerge, isPlainObject } from '../_utils/deep-merge'
+import { DEFAULT_STORAGE_KEY, defaultPreferences, defaultSizes } from './defaults'
+import { applyDataTheme, applyThemeVars, resolveDark } from './apply'
+
+interface PreferencesState {
+ /** 偏好主体 */
+ preferences: G3Preferences
+ /** 尺寸常量 */
+ sizes: LayoutSizes
+ /** 透传给 @g3soft/ui ConfigProvider 的主题(preferences.theme 会覆盖 primary / dark) */
+ theme: G3ThemeConfig
+ /** 持久化 key */
+ storageKey: string
+ /** 是否持久化 */
+ persist: boolean
+ /** 是否已初始化 */
+ started: boolean
+}
+
+const state = reactive({
+ preferences: deepClone(defaultPreferences),
+ sizes: { ...defaultSizes },
+ theme: { scope: 'global' },
+ storageKey: DEFAULT_STORAGE_KEY,
+ persist: true,
+ started: false,
+})
+
+let stopPersistWatch: (() => void) | null = null
+let mediaQuery: MediaQueryList | null = null
+
+/** 应用所有副作用:CSS 变量 + 暗色基座 */
+function applyAll(): void {
+ applyThemeVars(state.preferences.theme, state.sizes)
+ applyDataTheme(resolveDark(state.preferences.theme.mode))
+}
+
+/** 从 localStorage 读回已持久化的偏好(SSR / 隐私模式 / 解析失败均静默跳过) */
+function loadPersisted(): void {
+ if (typeof window === 'undefined') return
+ try {
+ const raw = window.localStorage.getItem(state.storageKey)
+ if (!raw) return
+ const parsed: unknown = JSON.parse(raw)
+ if (isPlainObject(parsed)) deepMerge(state.preferences, parsed)
+ } catch {
+ /* 忽略 */
+ }
+}
+
+function setupPersistence(): void {
+ if (typeof window === 'undefined' || !state.persist) return
+ loadPersisted()
+ stopPersistWatch?.()
+ stopPersistWatch = watch(
+ () => state.preferences,
+ () => {
+ try {
+ window.localStorage.setItem(state.storageKey, JSON.stringify(state.preferences))
+ } catch {
+ /* 忽略配额 / 隐私模式错误 */
+ }
+ },
+ { deep: true },
+ )
+}
+
+/** auto 模式:跟随系统亮暗变化 */
+function setupAutoThemeListener(): void {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
+ mediaQuery?.removeEventListener('change', applyAll)
+ mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ mediaQuery.addEventListener('change', applyAll)
+}
+
+const layout = computed({
+ get: () => state.preferences.app.layout,
+ set: (value) => {
+ state.preferences.app.layout = value
+ },
+})
+
+const collapsed = computed({
+ get: () => state.preferences.sidebar.collapsed,
+ set: (value) => {
+ state.preferences.sidebar.collapsed = value
+ },
+})
+
+/** 供 ConfigProvider 使用的主题:preferences 里的主色 / 暗色覆盖 store.theme */
+const resolvedTheme = computed(() => ({
+ ...state.theme,
+ primary: state.preferences.theme.colorPrimary,
+ dark: resolveDark(state.preferences.theme.mode),
+ scope: 'global',
+}))
+
+export interface G3PreferencesApi {
+ /** 只读偏好(响应式) */
+ preferences: Readonly
+ /** 只读尺寸 */
+ sizes: Readonly
+ /** 布局模式(可写 computed) */
+ layout: typeof layout
+ /** 侧栏折叠(可写 computed) */
+ collapsed: typeof collapsed
+ /** 解析后的 ConfigProvider 主题 */
+ resolvedTheme: typeof resolvedTheme
+ /** 深合并更新偏好 */
+ update: (patch: DeepPartial) => void
+ /** 更新尺寸常量 */
+ updateSizes: (patch: Partial) => void
+ /** 重置为默认值 */
+ reset: () => void
+ /** 设置布局模式 */
+ setLayout: (value: LayoutType) => void
+ /** 切换侧栏折叠 */
+ toggleSidebar: (value?: boolean) => void
+ /** 立即应用副作用(一般无需手动调用) */
+ apply: () => void
+}
+
+const api: G3PreferencesApi = {
+ get preferences() {
+ return readonly(state.preferences) as Readonly
+ },
+ get sizes() {
+ return readonly(state.sizes) as Readonly
+ },
+ layout,
+ collapsed,
+ resolvedTheme,
+ update(patch) {
+ deepMerge(state.preferences, patch)
+ },
+ updateSizes(patch) {
+ Object.assign(state.sizes, patch)
+ // 侧栏宽度与 sizes 同步(偏好里也有 width,保持二者一致)
+ },
+ reset() {
+ Object.assign(state.preferences, deepClone(defaultPreferences))
+ Object.assign(state.sizes, defaultSizes)
+ },
+ setLayout(value) {
+ state.preferences.app.layout = value
+ },
+ toggleSidebar(value) {
+ state.preferences.sidebar.collapsed = value ?? !state.preferences.sidebar.collapsed
+ },
+ apply: applyAll,
+}
+
+/** 获取偏好单例(模块级唯一,无需 pinia) */
+export function usePreferences(): G3PreferencesApi {
+ return api
+}
+
+/** 内部:初始化(由 createG3Framework 调用) */
+export function setupPreferences(patch: {
+ preferences?: DeepPartial
+ sizes?: Partial
+ theme?: G3ThemeConfig
+ storageKey?: string
+ persist?: boolean
+}): void {
+ if (patch.storageKey) state.storageKey = patch.storageKey
+ if (patch.persist != null) state.persist = patch.persist
+ if (patch.theme) Object.assign(state.theme, patch.theme)
+ if (patch.preferences) deepMerge(state.preferences, patch.preferences)
+ if (patch.sizes) Object.assign(state.sizes, patch.sizes)
+
+ setupPersistence()
+ setupAutoThemeListener()
+ applyAll()
+ state.started = true
+}
+
+/** 内部:供调试读取原始(非只读)状态 */
+export function __getPreferencesState(): PreferencesState {
+ return state
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/types/layout.ts b/code/g3soft-libs/packages/framework/src/core/types/layout.ts
new file mode 100644
index 00000000..ea66df2d
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/types/layout.ts
@@ -0,0 +1,171 @@
+import type { Component } from 'vue'
+
+/**
+ * 布局模式。7 种全部实现,做法是「一个 G3Layout 组件 + `layout` prop 条件渲染」,
+ * 不拆成 7 个组件。
+ */
+export type LayoutType =
+ /** 垂直侧边菜单(默认) */
+ | 'sidebar-nav'
+ /** 顶部水平菜单 */
+ | 'header-nav'
+ /** 双列菜单:左窄列一级 + 右列子级 */
+ | 'sidebar-mixed-nav'
+ /** 无侧栏无顶栏(登录页 / 大屏) */
+ | 'full-content'
+ /** 顶部一级 + 侧栏二级 */
+ | 'mixed-nav'
+ /** 顶部混合 */
+ | 'header-mixed-nav'
+ /** 顶栏 + 侧栏 */
+ | 'header-sidebar-nav'
+
+/**
+ * 布局尺寸常量。默认值沿用 fms 现状,业务可通过偏好(preferences.sidebar / header)
+ * 或安装配置(G3FrameworkOptions.sizes)覆盖。
+ */
+export interface LayoutSizes {
+ /** 侧栏展开宽度,默认 220 */
+ sidebarWidth: number
+ /** 侧栏折叠宽度,默认 48 */
+ sidebarCollapseWidth: number
+ /** 侧栏可拖拽的最大宽度,默认 400 */
+ sidebarMaxWidth: number
+ /** 双列布局的主(窄)列宽度,默认 80 */
+ mixedSidebarWidth: number
+ /** 顶栏高度,默认 56 */
+ headerHeight: number
+ /** 页签栏高度,默认 40 */
+ tabbarHeight: number
+}
+
+/**
+ * 菜单 / 导航节点 —— framework 唯一认识的菜单形状(数据契约核心)。
+ *
+ * 数据来源由宿主注入(`:menus` 直接给数组,或 `:menu-source` 给函数),
+ * framework 不关心它来自哪个后端、哪张表。
+ */
+export interface NavItem {
+ /** 唯一标识,通常等于路由 path 或菜单 id */
+ key: string
+ /** 显示文本 */
+ title: string
+ /** 图标:图标名字符串(由宿主解析)或组件 */
+ icon?: string | Component
+ /** 站内路由地址 */
+ path?: string
+ /** 外链地址 */
+ link?: string
+ /** 是否为外部链接(配合 link 使用) */
+ external?: boolean
+ /** 徽标 */
+ badge?: string | number
+ /** 是否禁用 */
+ disabled?: boolean
+ /** 是否在菜单中隐藏 */
+ hidden?: boolean
+ /** 子菜单 */
+ children?: NavItem[]
+ /** 宿主可携带的任意扩展数据 */
+ meta?: Record
+}
+
+/**
+ * 宿主路由 meta 的推荐结构。framework 按此解释路由信息,业务可自由扩展(索引签名)。
+ */
+export interface G3RouteMeta {
+ /** 标题(面包屑 / 页签 / 菜单回退显示) */
+ title?: string
+ /** 图标 */
+ icon?: string | Component
+ /** 权限 / 菜单标识(framework 不判定,交由宿主过滤) */
+ auth?: string | string[]
+ /** 是否在菜单中隐藏 */
+ hideInMenu?: boolean
+ /** 是否在页签栏中隐藏 */
+ hideInTab?: boolean
+ /** 是否在面包屑中隐藏 */
+ hideInBreadcrumb?: boolean
+ /** 是否 keep-alive 缓存页面 */
+ keepAlive?: boolean
+ /** 是否固定(affix)页签,不可关闭 */
+ affix?: boolean
+ /** 外链 */
+ link?: string
+ /** 菜单排序权重,越小越靠前 */
+ order?: number
+ /** 允许业务扩展 */
+ [key: string]: unknown
+}
+
+/**
+ * 当前登录用户。framework 仅用于展示(头像 / 名称),数据由宿主注入。
+ */
+export interface UserInfo {
+ id?: string | number
+ account?: string
+ name?: string
+ avatar?: string
+ roles?: string[]
+ /** 允许业务扩展(如 orgId 等) */
+ [key: string]: unknown
+}
+
+/**
+ * 菜单数据来源。宿主提供一个函数,framework 负责调用(含 loading / 失败 / 刷新)——
+ * 但函数体(调哪个接口、查哪张表)完全由宿主决定。这是"不绑死"的关键注入点。
+ */
+export type MenuSource = () => NavItem[] | Promise
+
+/**
+ * 布局零件的替换表。宿主可整体替换某个零件(如换自己的 Header),
+ * 而不只是改插槽内容。
+ */
+export interface LayoutComponentOverrides {
+ logo?: Component
+ header?: Component
+ sidebar?: Component
+ tabbar?: Component
+ breadcrumb?: Component
+}
+
+/**
+ * 页签项(页签栏展示用)。页签的「状态」(打开列表)由宿主或接线层管理,
+ * framework 只负责渲染与交互事件。
+ */
+export interface G3TabItem {
+ /** 唯一标识,通常等于路由 path */
+ key: string
+ /** 显示文本 */
+ title: string
+ /** 图标 */
+ icon?: string | Component
+ /** 是否可关闭(默认 true;affix 固定页签恒为不可关闭) */
+ closable?: boolean
+ /** 是否为固定页签 */
+ affix?: boolean
+}
+
+/**
+ * G3Layout 的 props(UI 契约之一)。所有 props 可选,零配置即可用默认值跑起来。
+ */
+export interface G3LayoutProps {
+ /** 布局模式。不传则取偏好(preferences.app.layout,默认 'sidebar-nav') */
+ layout?: LayoutType
+ /** 菜单数据(与 menuSource 二选一;直接给数组时不做加载) */
+ menus?: NavItem[]
+ /** 菜单数据源(与 menus 二选一;给函数时 framework 接管加载流程) */
+ menuSource?: MenuSource
+ /** 当前激活的菜单 key(通常等于当前路由 path) */
+ activeKey?: string
+ /** 当前用户(用于顶栏头像 / 名称展示) */
+ user?: UserInfo
+ /** 页签列表(不传则不渲染页签栏内容) */
+ tabs?: G3TabItem[]
+ /** 侧栏是否折叠(受控;不传则由偏好 / 内部状态管理) */
+ collapsed?: boolean
+ /** 布局零件替换表 */
+ components?: LayoutComponentOverrides
+ /** 尺寸覆盖(不传用默认,见 LayoutSizes) */
+ sizes?: Partial
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/types/preferences.ts b/code/g3soft-libs/packages/framework/src/core/types/preferences.ts
new file mode 100644
index 00000000..c1455eff
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/types/preferences.ts
@@ -0,0 +1,212 @@
+import type { G3ThemeConfig } from '@g3soft/ui'
+import type { LayoutType, LayoutSizes } from './layout'
+
+/** 深部分类型:用于「偏好初始值覆盖默认值」 */
+export type DeepPartial = {
+ [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]
+}
+
+/** 主题模式 */
+export type ThemeMode = 'light' | 'dark' | 'auto'
+
+/**
+ * app 组偏好。
+ * 相比 vben 砍掉了 accessMode / authPageLayout / checkUpdates / timezone。
+ */
+export interface AppPreferences {
+ /** 布局模式 */
+ layout: LayoutType
+ /** 应用名称(用于标题 / logo 文案回退) */
+ name?: string
+ /** 语言,如 'zh-CN' / 'en-US' */
+ locale?: string
+ /** 登录后默认首页路径 */
+ defaultHomePath?: string
+ /** 是否移动端(通常由框架自动判定,此处可在初始化时覆盖) */
+ isMobile?: boolean
+}
+
+/**
+ * theme 组偏好。
+ *
+ * 关键:这一组只作为「输入」,唯一「输出」仍是 @g3soft/ui 的
+ * `ConfigProvider → derivePalette → --g3-*`。framework 不另起一套 CSS 变量系统。
+ */
+export interface ThemePreferences {
+ /** 亮 / 暗 / 跟随系统 */
+ mode: ThemeMode
+ /** 主色种子色。只给一个种子色,色阶 / 交互态 / on-primary 由 derivePalette 自动派生 */
+ colorPrimary: string
+ /** 全局圆角(CSS 值,如 '6px'),映射到 --g3-radius */
+ radius?: string | number
+ /** 基础字号(CSS 值,如 '14px'),映射到 --g3-font-size-base */
+ fontSize?: string | number
+ /** 预设色板标识(选中即写入 colorPrimary) */
+ builtinType?: string
+ /** 半深色侧栏 —— 布局专属,首期只留类型不实现 */
+ semiDarkSidebar?: boolean
+ /** 半深色顶栏 —— 布局专属,首期只留类型不实现 */
+ semiDarkHeader?: boolean
+ /** 半深色子侧栏 —— 布局专属,首期只留类型不实现 */
+ semiDarkSubSidebar?: boolean
+}
+
+/** sidebar 组偏好 */
+export interface SidebarPreferences {
+ /** 展开宽度,默认 220 */
+ width: number
+ /** 折叠宽度,默认 48 */
+ collapseWidth: number
+ /** 双列主列宽度,默认 80 */
+ mixedWidth: number
+ /** 是否折叠 */
+ collapsed: boolean
+ /** 折叠时是否仍显示菜单标题 */
+ collapsedShowTitle?: boolean
+ /** 悬浮时是否临时展开 */
+ expandOnHover?: boolean
+ /** 是否可拖拽调宽 */
+ draggable?: boolean
+ /** 是否隐藏侧栏 */
+ hidden?: boolean
+}
+
+/** 顶栏模式 */
+export type HeaderMode = 'fixed' | 'static' | 'auto'
+
+/** header 组偏好 */
+export interface HeaderPreferences {
+ /** 是否显示顶栏 */
+ enable: boolean
+ /** 顶栏高度,默认 56 */
+ height: number
+ /** 是否隐藏 */
+ hidden?: boolean
+ /** 固定 / 静态 / 自动 */
+ mode?: HeaderMode
+}
+
+/** 页签栏风格 */
+export type TabbarStyleType = 'chrome' | 'plain'
+
+/** tabbar 组偏好 */
+export interface TabbarPreferences {
+ /** 是否启用页签栏 */
+ enable: boolean
+ /** 风格 */
+ styleType?: TabbarStyleType
+ /** 是否显示页签图标 */
+ showIcon?: boolean
+ /** 是否显示刷新按钮 */
+ showRefresh?: boolean
+ /** 是否显示更多按钮 */
+ showMore?: boolean
+ /** 是否显示最大化按钮 */
+ showMaximize?: boolean
+ /** 是否 keep-alive 缓存 */
+ keepAlive?: boolean
+ /** 是否可拖拽排序 */
+ draggable?: boolean
+ /** 是否持久化页签 */
+ persist?: boolean
+ /** 最大页签数(超过按 LRU 关闭) */
+ maxCount?: number
+ /** 中键点击是否关闭页签 */
+ middleClickToClose?: boolean
+}
+
+/** 导航风格 */
+export type NavigationStyleType = 'rounded' | 'plain'
+
+/** navigation 组偏好 */
+export interface NavigationPreferences {
+ /** 菜单项风格 */
+ styleType?: NavigationStyleType
+ /** 是否手风琴(同层只展开一个) */
+ accordion?: boolean
+ /** 是否分栏(顶级与子级分列) */
+ split?: boolean
+}
+
+/** 面包屑风格 */
+export type BreadcrumbStyleType = 'normal' | 'background'
+
+/** breadcrumb 组偏好 */
+export interface BreadcrumbPreferences {
+ /** 是否显示面包屑 */
+ enable: boolean
+ /** 是否显示图标 */
+ showIcon?: boolean
+ /** 是否显示首页 */
+ showHome?: boolean
+ /** 风格 */
+ styleType?: BreadcrumbStyleType
+}
+
+/** logo 偏好(从 vben 的独立组合并而来) */
+export interface LogoPreferences {
+ /** 是否显示 logo */
+ enable?: boolean
+ /** logo 图片地址 */
+ source?: string
+ /** logo 文案 */
+ text?: string
+}
+
+/** footer 偏好(合并组) */
+export interface FooterPreferences {
+ /** 是否显示页脚 */
+ enable?: boolean
+ /** 页脚文案 */
+ text?: string
+}
+
+/** content 组偏好(合并组) */
+export interface ContentPreferences {
+ /** 是否紧凑模式 */
+ compact?: boolean
+ /** 内容区内边距(CSS 值) */
+ padding?: string | number
+ /** 页面过渡动画名,默认 'fade-slide'(不做成偏好项,仅保留字段) */
+ transition?: string
+}
+
+/**
+ * 偏好总模型。
+ *
+ * 主要 7 组:app / theme / sidebar / header / tabbar / navigation / breadcrumb;
+ * 另合并了 logo / footer / content 三小组(原 vben 里更细的组被砍)。
+ * 砍掉的组:shortcutKeys / widget / transition(独立组) / copyright。
+ */
+export interface G3Preferences {
+ app: AppPreferences
+ theme: ThemePreferences
+ sidebar: SidebarPreferences
+ header: HeaderPreferences
+ tabbar: TabbarPreferences
+ navigation: NavigationPreferences
+ breadcrumb: BreadcrumbPreferences
+ logo: LogoPreferences
+ footer: FooterPreferences
+ content: ContentPreferences
+}
+
+/**
+ * 安装配置(配置契约)—— 宿主在 main.ts 里一次性传入。
+ * 所有字段可选,零配置即可用默认值启动。
+ *
+ * 注意:接口层(request / ApiResponse / 默认菜单源)不在 core,后续以 preset 形式接入,
+ * 因此这里暂不含 request 相关配置。
+ */
+export interface G3FrameworkOptions {
+ /** 偏好初始值,深合并到默认值之上 */
+ preferences?: DeepPartial
+ /** 尺寸常量覆盖(侧栏 / 顶栏 / 页签等) */
+ sizes?: Partial
+ /** 主题配置:直接对接 @g3soft/ui 的 G3ThemeConfig,不另起体系 */
+ theme?: G3ThemeConfig
+ /** 偏好持久化的命名空间 / storage key,默认 'g3-preferences' */
+ storageKey?: string
+ /** 是否持久化偏好,默认 true */
+ persist?: boolean
+}
diff --git a/code/g3soft-libs/packages/framework/src/core/types/slots.ts b/code/g3soft-libs/packages/framework/src/core/types/slots.ts
new file mode 100644
index 00000000..ce2be84a
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/core/types/slots.ts
@@ -0,0 +1,44 @@
+import type { NavItem } from './layout'
+
+/**
+ * G3Layout 插槽(UI 契约之一)。
+ *
+ * 原则:插槽宁可多不要少;每个插槽都有默认实现,宿主不填即用默认。
+ * 插槽参数目前只给必要上下文,后续实现阶段可扩。
+ */
+export interface G3LayoutSlots {
+ /** 品牌区(侧栏顶部 / 顶栏左侧) */
+ logo?: (props: { collapsed: boolean }) => any
+ /** 顶栏左侧(默认:面包屑) */
+ 'header-left'?: (props: { collapsed: boolean }) => any
+ /** 顶栏右侧(默认:主题切换 + 用户菜单) */
+ 'header-right'?: (props: { collapsed: boolean }) => any
+ /** 侧栏顶部(logo 下方) */
+ 'sidebar-top'?: (props: { collapsed: boolean }) => any
+ /** 侧栏底部(菜单下方) */
+ 'sidebar-footer'?: (props: { collapsed: boolean }) => any
+ /** 页签栏右侧扩展区 */
+ 'tabbar-extra'?: (props: {}) => any
+ /** 面包屑右侧扩展区 */
+ 'breadcrumb-extra'?: (props: {}) => any
+ /** 页面内容区(唯一必需;布局的主内容由此承载) */
+ page?: (props: {}) => any
+ /** 兜底:完全自定义整个布局(提供后覆盖内置布局) */
+ default?: (props: {}) => any
+}
+
+/**
+ * G3Layout 事件(UI 契约之一)。
+ */
+export interface G3LayoutEmits {
+ /** 点击菜单项:宿主据此做路由跳转(framework 不直接依赖 vue-router) */
+ (e: 'navigate', payload: { item: NavItem; key: string; event?: MouseEvent }): void
+ /** 菜单选中变化 */
+ (e: 'menu-select', key: string): void
+ /** 侧栏折叠状态变化 */
+ (e: 'update:collapsed', collapsed: boolean): void
+ /** 页签切换 */
+ (e: 'tab-change', key: string): void
+ /** 页签关闭 */
+ (e: 'tab-close', key: string): void
+}
diff --git a/code/g3soft-libs/packages/framework/src/index.ts b/code/g3soft-libs/packages/framework/src/index.ts
new file mode 100644
index 00000000..58004182
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/src/index.ts
@@ -0,0 +1,3 @@
+// @g3soft/framework 公共入口。
+// 当前仅 core(布局骨架 + 契约)。接口层将以 preset 子路径(如 /presets/g3-backend)后续接入。
+export * from './core'
diff --git a/code/g3soft-libs/packages/framework/tsconfig.json b/code/g3soft-libs/packages/framework/tsconfig.json
new file mode 100644
index 00000000..c5bb749e
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "jsx": "preserve",
+ "strict": true,
+ "noImplicitOverride": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
+}
diff --git a/code/g3soft-libs/packages/framework/vite.config.ts b/code/g3soft-libs/packages/framework/vite.config.ts
new file mode 100644
index 00000000..3de59175
--- /dev/null
+++ b/code/g3soft-libs/packages/framework/vite.config.ts
@@ -0,0 +1,40 @@
+import { fileURLToPath } from 'node:url'
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+import dts from 'vite-plugin-dts'
+
+/**
+ * @g3soft/framework 构建:产出 ESM + 类型声明。
+ *
+ * 关键点(与 @g3soft/ui 一致、刻意保持轻量):
+ * - vue 外置:绝不打包 Vue,避免消费方双实例
+ * - @g3soft/ui 外置:framework 建立在组件库之上,由消费方按正常依赖解析,避免重复打包
+ * - preserveModules 保留 src 结构,消费方可 tree-shaking
+ * - vite-plugin-dts 的 rollupTypes 产出单文件 dist/index.d.ts,配合 exports.types
+ */
+export default defineConfig({
+ plugins: [
+ vue(),
+ // 每个源文件产出对应 .d.ts,src/index.ts → dist/index.d.ts(桶文件,re-export ./core/...)。
+ // 不做类型打包(bundleTypes 需额外装 @microsoft/api-extractor,与 @g3soft/ui 保持一致地省略)。
+ dts({
+ include: ['src'],
+ }),
+ ],
+ build: {
+ lib: {
+ entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
+ formats: ['es'],
+ fileName: () => 'index.js',
+ },
+ rollupOptions: {
+ external: ['vue', '@g3soft/ui'],
+ output: {
+ preserveModules: true,
+ preserveModulesRoot: 'src',
+ entryFileNames: '[name].js',
+ assetFileNames: 'style.css',
+ },
+ },
+ },
+})
diff --git a/code/g3soft-libs/packages/tokens/src/scss/theme.scss b/code/g3soft-libs/packages/tokens/src/scss/theme.scss
index 1e979d43..ee4d2c24 100644
--- a/code/g3soft-libs/packages/tokens/src/scss/theme.scss
+++ b/code/g3soft-libs/packages/tokens/src/scss/theme.scss
@@ -66,6 +66,29 @@ $dark-primary: #e5e5e5;
/* ---------- 阴影 ---------- */
#{fn.css-var('shadow')}: 0 2px 8px rgba(0, 0, 0, 0.15);
+ /* 浮层阴影:下拉 / 气泡 / 日期面板等 */
+ #{fn.css-var('shadow-popup')}: 0 6px 16px rgba(0, 0, 0, 0.12);
+
+ /* ---------- 交互填充(hover 底 / 轨道底 / 次级面)---------- */
+ #{fn.css-var('fill')}: rgba(0, 0, 0, 0.04);
+ #{fn.css-var('fill-hover')}: rgba(0, 0, 0, 0.08);
+
+ /* ---------- 主色淡底(选中 / hover 高亮),跟随主色换肤 ----------
+ color-mix() 的颜色参数必须是 var(...):只写变量名(--g3-color-primary)
+ 会让整个函数解析失败,变量值为空 → 用到它的地方全都「没有背景色」 */
+ #{fn.css-var('color-primary-soft')}: color-mix(
+ in srgb,
+ var(#{fn.css-var('color-primary')}) 12%,
+ transparent
+ );
+
+ /* ---------- 遮罩 ---------- */
+ #{fn.css-var('mask-bg')}: rgba(0, 0, 0, 0.45);
+
+ /* ---------- 行高与动效档位 ---------- */
+ #{fn.css-var('line-height')}: 1.5;
+ #{fn.css-var('duration-fast')}: 0.1s;
+ #{fn.css-var('duration-slow')}: 0.3s;
}
// 暗色主题:只覆盖变量值,不改变量名。
@@ -87,4 +110,16 @@ html[data-#{config.$namespace}-theme='dark'] {
#{fn.css-var('border-color')}: #424242;
#{fn.css-var('shadow')}: 0 2px 8px rgba(0, 0, 0, 0.45);
+ #{fn.css-var('shadow-popup')}: 0 6px 16px rgba(0, 0, 0, 0.5);
+
+ #{fn.css-var('fill')}: rgba(255, 255, 255, 0.06);
+ #{fn.css-var('fill-hover')}: rgba(255, 255, 255, 0.12);
+
+ #{fn.css-var('color-primary-soft')}: color-mix(
+ in srgb,
+ var(#{fn.css-var('color-primary')}) 20%,
+ transparent
+ );
+
+ #{fn.css-var('mask-bg')}: rgba(0, 0, 0, 0.6);
}
diff --git a/code/g3soft-libs/packages/ui/e2e/data.spec.ts b/code/g3soft-libs/packages/ui/e2e/data.spec.ts
new file mode 100644
index 00000000..0f2e5601
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/data.spec.ts
@@ -0,0 +1,171 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * 数据展示类组件的交互回归。
+ *
+ * 注意:用 page.mouse.* 之前必须先 scrollIntoViewIfNeeded ——
+ * mouse 事件按视口坐标派发,元素在视口外时事件会落到别的元素上
+ * (表现为「拖拽/点击完全没反应」,很容易被误判成组件 bug)。
+ */
+
+test.describe('Tabs', () => {
+ test('面板懒渲染,切换后保留旧面板(表单状态不丢)', async ({ page }) => {
+ await page.goto('/')
+ const tabs = page.getByTestId('tabs')
+
+ await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(1)
+ await tabs.locator('.g3-tabs__tab').nth(1).click()
+ await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(2)
+ await expect(tabs.locator('.g3-tabs__pane').first()).toBeHidden()
+ })
+
+ test('禁用标签不可点击(回归:空值布尔属性被 Boolean("") 判成 false)', async ({ page }) => {
+ await page.goto('/')
+ const tabs = page.getByTestId('tabs')
+ const disabled = tabs.locator('.g3-tabs__tab').nth(2)
+
+ await expect(disabled).toBeDisabled()
+ await expect(disabled).toHaveAttribute('aria-disabled', 'true')
+ await expect(tabs.locator('.g3-tabs__pane')).toHaveCount(1)
+ })
+})
+
+test.describe('Tree', () => {
+ test('勾选父节点后子节点全选(父子联动)', async ({ page }) => {
+ await page.goto('/')
+ const tree = page.getByTestId('tree')
+ const checkboxes = tree.locator('.g3-tree-node__checkbox .g3-checkbox')
+
+ await expect(checkboxes).toHaveCount(6)
+ await checkboxes.first().click()
+
+ const checkedCount = await tree.locator('.g3-tree-node__checkbox .g3-checkbox.is-checked').count()
+ // 「前端」及其 3 个子孙(Vue / 组合式 API / React)
+ expect(checkedCount).toBe(4)
+ })
+
+ test('收起父节点后其子孙不再渲染(后端仍展开,所以剩下 3 个)', async ({ page }) => {
+ await page.goto('/')
+ const tree = page.getByTestId('tree')
+
+ await expect(tree.locator('.g3-tree-node')).toHaveCount(6)
+ await tree.locator('.g3-tree-node__switcher').first().click()
+ await expect(tree.locator('.g3-tree-node')).toHaveCount(3)
+ })
+})
+
+test.describe('Pagination', () => {
+ test('省略号点击跳 5 页', async ({ page }) => {
+ await page.goto('/')
+ const pagination = page.getByTestId('pagination')
+
+ await expect(pagination.locator('[aria-current="page"]')).toHaveText('1')
+ await pagination.locator('.g3-pagination__ellipsis').click()
+ await expect(pagination.locator('[aria-current="page"]')).toHaveText('6')
+ })
+
+ test('显示总数与跳页控件', async ({ page }) => {
+ await page.goto('/')
+ const pagination = page.getByTestId('pagination')
+
+ await expect(pagination.locator('.g3-pagination__total')).toContainText('126')
+ await expect(pagination.locator('.g3-pagination__jumper')).toBeVisible()
+ })
+})
+
+test.describe('Splitter', () => {
+ test('拖拽分隔条同时改变分隔条位置与两侧尺寸', async ({ page }) => {
+ await page.goto('/')
+ const splitter = page.getByTestId('splitter')
+ await splitter.scrollIntoViewIfNeeded()
+
+ const panels = splitter.locator('.g3-splitter-panel')
+ const bar = splitter.locator('.g3-splitter__bar')
+
+ // 探针:区分「pointerdown 没落到分隔条上」与「落上了但逻辑没生效」
+ await page.evaluate(() => {
+ ;(window as unknown as { __barDown: number }).__barDown = 0
+ document.querySelector('.g3-splitter__bar')?.addEventListener('pointerdown', () => {
+ ;(window as unknown as { __barDown: number }).__barDown += 1
+ })
+ })
+
+ const before = (await panels.first().boundingBox())!.width
+ const box = (await bar.boundingBox())!
+ const centerX = box.x + box.width / 2
+ const centerY = box.y + box.height / 2
+
+ await page.mouse.move(centerX, centerY)
+ await page.mouse.down()
+ await page.mouse.move(centerX + 40, centerY, { steps: 3 })
+ await page.mouse.move(centerX + 80, centerY, { steps: 3 })
+ await page.mouse.up()
+ await page.waitForTimeout(150)
+
+ const barDown = await page.evaluate(() => (window as unknown as { __barDown: number }).__barDown)
+ expect(barDown).toBe(1)
+ expect((await panels.first().boundingBox())!.width).toBeGreaterThan(before + 20)
+ })
+
+ test('分隔条可键盘调整(方向键 ±10px)', async ({ page }) => {
+ await page.goto('/')
+ const splitter = page.getByTestId('splitter')
+ await splitter.scrollIntoViewIfNeeded()
+
+ const panels = splitter.locator('.g3-splitter-panel')
+ const before = (await panels.first().boundingBox())!.width
+
+ await splitter.locator('.g3-splitter__bar').focus()
+ await page.keyboard.press('ArrowRight')
+ await page.keyboard.press('ArrowRight')
+
+ expect((await panels.first().boundingBox())!.width).toBeGreaterThan(before + 10)
+ })
+})
+
+test.describe('Scrollbar', () => {
+ test('点击轨道跳转滚动(clickScroll 默认开启)', async ({ page }) => {
+ await page.goto('/')
+ const scrollbar = page.getByTestId('scrollbar')
+ await scrollbar.scrollIntoViewIfNeeded()
+
+ const view = scrollbar.locator('.g3-scrollbar__view')
+ expect(await view.evaluate((el) => el.scrollTop)).toBe(0)
+
+ const track = (await scrollbar.locator('.g3-scrollbar__track-v').boundingBox())!
+ await page.mouse.click(track.x + track.width / 2, track.y + track.height * 0.7)
+ await page.waitForTimeout(100)
+
+ expect(await view.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
+ })
+
+ test('拖动滑块同步滚动位置', async ({ page }) => {
+ await page.goto('/')
+ const scrollbar = page.getByTestId('scrollbar')
+ await scrollbar.scrollIntoViewIfNeeded()
+
+ const view = scrollbar.locator('.g3-scrollbar__view')
+ const box = (await scrollbar.locator('.g3-scrollbar__thumb-v').boundingBox())!
+ const centerX = box.x + box.width / 2
+ const centerY = box.y + box.height / 2
+
+ await page.mouse.move(centerX, centerY)
+ await page.mouse.down()
+ await page.mouse.move(centerX, centerY + 60, { steps: 5 })
+ await page.mouse.up()
+ await page.waitForTimeout(100)
+
+ expect(await view.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
+ })
+})
+
+test.describe('Segmented', () => {
+ test('点击选项切换选中态(滑块跟随)', async ({ page }) => {
+ await page.goto('/')
+ const segmented = page.getByTestId('segmented')
+
+ await expect(segmented.locator('.g3-segmented__item.is-selected')).toHaveText('日')
+ await segmented.locator('.g3-segmented__item').filter({ hasText: '周' }).click()
+ await expect(segmented.locator('.g3-segmented__item.is-selected')).toHaveText('周')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/drawer.spec.ts b/code/g3soft-libs/packages/ui/e2e/drawer.spec.ts
new file mode 100644
index 00000000..c1b8dfd0
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/drawer.spec.ts
@@ -0,0 +1,131 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * Drawer 四个方向的几何回归。
+ *
+ * 漏测教训:之前只验了默认的 `right`,导致「上下方向的抽屉宽度不是 100%」一直没被发现。
+ * 根因是容器用 flex 的 justify-content / align-items 做吸附 —— 两条轴各管一个方向,
+ * 上下面板在主轴(水平)方向没有约束,宽度缩到了内容宽。
+ * 现在改为按方向定边(上下 `left/right: 0`、左右 `top/bottom: 0`),与 fms 一致。
+ *
+ * 断言以 `.g3-drawer__wrap`(定位容器)为参照,而不是写死视口尺寸:
+ * 视口高度受 `devices['Desktop Chrome']` 与滚动条影响,写死会得到假失败。
+ * 这类断言必须放 E2E:真实布局结果在 happy-dom 里量不出来。
+ */
+
+/** 打开指定方向的抽屉,返回面板与定位容器的几何 */
+async function openDrawer(page: import('@playwright/test').Page, testId: string) {
+ await page.getByTestId(testId).click()
+ const drawer = page.locator('.g3-drawer')
+ await expect(drawer).toBeVisible()
+ // 等入场动画结束(transform 归零),否则量到的是滑入途中的位置
+ await page.waitForTimeout(450)
+
+ const panel = (await drawer.boundingBox())!
+ const wrap = (await page.locator('.g3-drawer__wrap').boundingBox())!
+ return { drawer, panel, wrap }
+}
+
+test.describe('Drawer 方向与尺寸', () => {
+ test('右侧:贴右边缘、纵向撑满,宽度取 width', async ({ page }) => {
+ await page.goto('/')
+ const { panel, wrap } = await openDrawer(page, 'open-drawer')
+
+ expect(Math.round(panel.x + panel.width)).toBeCloseTo(Math.round(wrap.x + wrap.width), 0)
+ expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
+ expect(Math.round(panel.width)).toBe(378)
+ })
+
+ test('左侧:贴左边缘、纵向撑满', async ({ page }) => {
+ await page.goto('/')
+ const { panel, wrap } = await openDrawer(page, 'open-drawer-left')
+
+ expect(Math.round(panel.x)).toBeCloseTo(Math.round(wrap.x), 0)
+ expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
+ expect(Math.round(panel.width)).toBe(378)
+ })
+
+ test('上方:贴顶、横向撑满(回归:宽度曾是内容宽)', async ({ page }) => {
+ await page.goto('/')
+ const { panel, wrap } = await openDrawer(page, 'open-drawer-top')
+
+ expect(Math.round(panel.y)).toBeCloseTo(Math.round(wrap.y), 0)
+ expect(Math.round(panel.width)).toBeCloseTo(Math.round(wrap.width), 0)
+ expect(Math.round(panel.height)).toBe(378)
+ })
+
+ test('下方:贴底、横向撑满(回归:宽度曾是内容宽)', async ({ page }) => {
+ await page.goto('/')
+ const { panel, wrap } = await openDrawer(page, 'open-drawer-bottom')
+
+ expect(Math.round(panel.y + panel.height)).toBeCloseTo(Math.round(wrap.y + wrap.height), 0)
+ expect(Math.round(panel.width)).toBeCloseTo(Math.round(wrap.width), 0)
+ expect(Math.round(panel.height)).toBe(378)
+ })
+
+ test('四个方向都能撑满对应轴,且面板不被自身遮罩压住', async ({ page }) => {
+ await page.goto('/')
+
+ for (const [testId, name, axis] of [
+ ['open-drawer', '右', 'height'],
+ ['open-drawer-left', '左', 'height'],
+ ['open-drawer-top', '上', 'width'],
+ ['open-drawer-bottom', '下', 'width'],
+ ] as const) {
+ const { drawer, panel, wrap } = await openDrawer(page, testId)
+
+ if (axis === 'height') {
+ expect(Math.round(panel.height), `${name}侧抽屉未撑满高度`).toBeCloseTo(
+ Math.round(wrap.height),
+ 0,
+ )
+ } else {
+ expect(Math.round(panel.width), `${name}侧抽屉未撑满宽度`).toBeCloseTo(
+ Math.round(wrap.width),
+ 0,
+ )
+ }
+
+ const hit = await page.evaluate(
+ ([x, y]) => {
+ const el = document.elementFromPoint(x as number, y as number)
+ return el ? String(el.className || el.tagName) : null
+ },
+ [panel.x + panel.width / 2, panel.y + Math.min(20, panel.height / 2)],
+ )
+ expect(hit, `${name}侧抽屉面板被遮挡`).toContain('g3-drawer')
+
+ await page.keyboard.press('Escape')
+ await expect(drawer).toBeHidden()
+ }
+ })
+
+ test('面板尺寸由组件自己决定,不受宿主页面全局样式污染', async ({ page }) => {
+ await page.goto('/')
+ // 模拟用户页面的「全局元素选择器」:面板本体是 ,header / footer 同理
+ await page.addStyleTag({
+ content: `
+ section { margin: 16px; padding: 24px; border: 2px solid red; }
+ header { margin: 12px; padding: 20px; }
+ footer { margin: 12px; padding: 20px; }
+ `,
+ })
+
+ const { panel, wrap } = await openDrawer(page, 'open-drawer')
+
+ const styles = await page.$eval('.g3-drawer', (el) => {
+ const cs = getComputedStyle(el)
+ return {
+ margin: [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft],
+ padding: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft],
+ borderWidth: cs.borderTopWidth,
+ }
+ })
+
+ expect(styles.margin).toEqual(['0px', '0px', '0px', '0px'])
+ expect(styles.padding).toEqual(['0px', '0px', '0px', '0px'])
+ expect(styles.borderWidth).toBe('0px')
+ // 仍然撑满:宿主 margin 不该从高度里扣掉 16px
+ expect(Math.round(panel.height)).toBeCloseTo(Math.round(wrap.height), 0)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/feedback.spec.ts b/code/g3soft-libs/packages/ui/e2e/feedback.spec.ts
new file mode 100644
index 00000000..60ffcaf8
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/feedback.spec.ts
@@ -0,0 +1,138 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * 反馈类组件回归。
+ *
+ * 曾经踩过的坑:
+ * - Message 的方位类名手写成 `g3-message-list--*`(样式表是 `g3-message__list--*`),
+ * 9 个方位全部失效、消息一律堆在左上角;
+ * - Modal 面板不是弹性布局,缩放后内容溢出面板而不是内部滚动。
+ */
+
+test.describe('Message 方位', () => {
+ test('默认方位是顶部居中', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('toast-default').click()
+
+ const message = page.locator('.g3-message').first()
+ await expect(message).toBeVisible()
+
+ const messageBox = (await message.boundingBox())!
+ const viewport = page.viewportSize()!
+ const center = messageBox.x + messageBox.width / 2
+
+ expect(Math.abs(center - viewport.width / 2)).toBeLessThan(2)
+ expect(messageBox.y).toBeLessThan(viewport.height * 0.25)
+ })
+
+ test('指定方位生效:左上 / 右下', async ({ page }) => {
+ await page.goto('/')
+ const viewport = page.viewportSize()!
+
+ await page.getByTestId('toast-top-left').click()
+ const topLeft = (await page.locator('.g3-message').first().boundingBox())!
+ expect(topLeft.x).toBeLessThan(48)
+ expect(topLeft.y).toBeLessThan(viewport.height * 0.25)
+
+ await page.waitForTimeout(3200)
+
+ await page.getByTestId('toast-bottom-right').click()
+ const bottomRight = (await page.locator('.g3-message').first().boundingBox())!
+ expect(bottomRight.x + bottomRight.width).toBeGreaterThan(viewport.width - 48)
+ expect(bottomRight.y).toBeGreaterThan(viewport.height * 0.6)
+ })
+})
+
+test.describe('Notification', () => {
+ test('默认出现在右上角', async ({ page }) => {
+ await page.goto('/')
+ const viewport = page.viewportSize()!
+ await page.getByTestId('notify').click()
+
+ const notification = page.locator('.g3-notification').first()
+ await expect(notification).toBeVisible()
+
+ const box = (await notification.boundingBox())!
+ expect(box.x + box.width).toBeGreaterThan(viewport.width - 48)
+ expect(box.y).toBeLessThan(viewport.height * 0.3)
+ })
+})
+
+test.describe('Modal', () => {
+ test('缩放变矮后内容区内部滚动,而不是溢出面板', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-modal').click()
+
+ const modal = page.locator('.g3-modal')
+ await expect(modal).toBeVisible()
+
+ // 把南侧手柄往上拖,压缩面板高度
+ const handle = (await page.locator('.g3-modal__resizer-s').boundingBox())!
+ const startX = handle.x + handle.width / 2
+ await page.mouse.move(startX, handle.y + handle.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(startX, handle.y - 400, { steps: 8 })
+ await page.mouse.up()
+
+ const panel = (await modal.boundingBox())!
+ const body = await page.evaluate(() => {
+ const el = document.querySelector('.g3-modal__body') as HTMLElement
+ return { client: el.clientHeight, scroll: el.scrollHeight }
+ })
+
+ // 面板被压矮,内容比可视区高 → 必须内部滚动
+ expect(panel.height).toBeLessThan(700)
+ expect(body.scroll).toBeGreaterThan(body.client)
+ })
+
+ test('Esc 关闭,遮罩点击可关闭', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-modal').click()
+ await expect(page.locator('.g3-modal')).toBeVisible()
+
+ await page.keyboard.press('Escape')
+ await expect(page.locator('.g3-modal')).toBeHidden()
+
+ await page.getByTestId('open-modal').click()
+ await page.mouse.click(60, 400)
+ await expect(page.locator('.g3-modal')).toBeHidden()
+ })
+
+ test('焦点被限制在面板内(Tab 循环)', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-modal').click()
+ await expect(page.locator('.g3-modal')).toBeVisible()
+
+ for (let index = 0; index < 8; index += 1) {
+ await page.keyboard.press('Tab')
+ }
+
+ const inside = await page.evaluate(() => {
+ const panel = document.querySelector('.g3-modal')
+ return panel ? panel.contains(document.activeElement) : false
+ })
+ expect(inside).toBe(true)
+ })
+})
+
+test.describe('Modal.confirm(命令式)', () => {
+ test('点确定后按钮进入 loading,完成后自动关闭', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-confirm').click()
+
+ const modal = page.locator('.g3-modal')
+ await expect(modal).toBeVisible()
+ await expect(modal.locator('.g3-modal__title')).toHaveText('删除确认')
+
+ const confirmButton = modal.locator('.g3-modal__footer button').last()
+ await expect(confirmButton).toHaveText('确定')
+ await confirmButton.click()
+
+ // loading 期间按钮禁用且带 aria-busy
+ await expect(confirmButton).toBeDisabled()
+ await expect(confirmButton).toHaveAttribute('aria-busy', 'true')
+
+ // onConfirm 的 Promise resolve 后自动关闭
+ await expect(modal).toBeHidden({ timeout: 5000 })
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/form.spec.ts b/code/g3soft-libs/packages/ui/e2e/form.spec.ts
new file mode 100644
index 00000000..1985435b
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/form.spec.ts
@@ -0,0 +1,113 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * 表单类组件的真实布局回归。
+ *
+ * 曾经踩过的坑:
+ * - textarea 的 footer(计数 + 拖拽手柄)在文档流里,被挤到框外;
+ * - 日期时间面板上下排列而不是「左日期 / 右时间」;
+ * - 日期面板宽度跟随输入框被拉伸,日历格子间距被撑散;
+ * - 非法日期(2026-02-30)被原生解析静默顺延成 3 月 2 日。
+ */
+
+test.describe('Input / textarea', () => {
+ test('拖拽手柄落在输入框内部右下角(不再被挤到框外)', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="textarea-count"]')
+ const box = (await root.boundingBox())!
+ const handle = (await root.locator('.g3-input__resize-handle').boundingBox())!
+
+ expect(handle.x + handle.width).toBeLessThanOrEqual(box.x + box.width + 1)
+ expect(handle.y + handle.height).toBeLessThanOrEqual(box.y + box.height + 1)
+ expect(handle.y).toBeGreaterThan(box.y + box.height / 2)
+ })
+
+ test('字数统计与手柄同处一行,且有可访问语义', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="textarea-count"]')
+
+ await expect(root.locator('.g3-input__count')).toHaveText(/\d+ \/ 100/)
+ await expect(root.locator('.g3-input__resize-handle')).toHaveAttribute('role', 'slider')
+ await expect(root.locator('.g3-input__resize-handle')).toHaveAttribute('aria-orientation', 'vertical')
+ })
+
+ test('number 输入实时净化,只保留一个小数点', async ({ page }) => {
+ await page.goto('/')
+ const input = page.locator('[data-testid="input-number"] input')
+
+ await input.fill('12.3.4')
+ await expect(input).toHaveValue('12.34')
+ })
+
+ test('password 可切换明文', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="input-password"]')
+
+ await expect(root.locator('input')).toHaveAttribute('type', 'password')
+ await root.locator('.g3-input__password-toggle').click()
+ await expect(root.locator('input')).toHaveAttribute('type', 'text')
+ })
+})
+
+test.describe('DatePicker', () => {
+ test('日期时间面板呈「左日历 / 右时间」,且日历定宽不被拉伸', async ({ page }) => {
+ await page.goto('/')
+ const trigger = page.locator('[data-testid="date-picker"] .g3-date-picker__trigger')
+ const triggerWidth = (await trigger.boundingBox())!.width
+
+ await trigger.click()
+ const panel = page.locator('.g3-date-picker__panel')
+ await expect(panel).toBeVisible()
+
+ const calendar = panel.locator('.g3-calendar__main')
+ const time = panel.locator('.g3-calendar__time')
+ await expect(calendar).toBeVisible()
+ await expect(time).toBeVisible()
+
+ const calendarBox = (await calendar.boundingBox())!
+ const timeBox = (await time.boundingBox())!
+
+ // 日历在左、时间列在右,同一行(纵向重叠)
+ expect(timeBox.x).toBeGreaterThan(calendarBox.x + calendarBox.width - 1)
+ expect(timeBox.y).toBeLessThan(calendarBox.y + calendarBox.height)
+
+ // 日历定宽,面板不跟随输入框拉宽
+ expect(calendarBox.width).toBeLessThan(260)
+ const panelBox = (await panel.boundingBox())!
+ expect(panelBox.width).toBeLessThan(triggerWidth)
+ })
+
+ test('面板内的确定按钮只有点选后才能用', async ({ page }) => {
+ await page.goto('/')
+ await page.locator('[data-testid="date-picker"] .g3-date-picker__trigger').click()
+
+ const confirm = page.locator('.g3-calendar__footer button')
+ await expect(confirm).toBeEnabled()
+ })
+
+ test('非法日期不会被接受(不再静默顺延)', async ({ page }) => {
+ await page.goto('/')
+ const input = page.locator('[data-testid="date-picker"] .g3-date-picker__input')
+
+ await input.click()
+ await input.fill('2026-02-30')
+ await input.press('Enter')
+
+ // 回退到原值,不能变成 2026-03-02
+ await expect(input).not.toHaveValue('2026-03-02')
+ await expect(page.locator('[data-testid="date-picker"]')).toHaveClass(/is-invalid/)
+ })
+})
+
+test.describe('RangePicker', () => {
+ test('区间面板打开且两端都有值', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="range-picker"]')
+
+ await expect(root.locator('input').first()).toHaveValue('2026-10-01')
+ await expect(root.locator('input').nth(1)).toHaveValue('2026-10-08')
+
+ await root.locator('.g3-range-picker__trigger').click()
+ await expect(page.locator('.g3-range-picker__panel')).toBeVisible()
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/layer.spec.ts b/code/g3soft-libs/packages/ui/e2e/layer.spec.ts
new file mode 100644
index 00000000..7b679d6f
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/layer.spec.ts
@@ -0,0 +1,121 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * 弹层层级回归 —— 这类问题单测覆盖不到(需要真实层叠上下文与命中测试)。
+ *
+ * 曾经踩过的坑:
+ * 1. 档位顺序写反(popup < modal)→ 弹窗/抽屉内的下拉被遮罩盖住;
+ * 2. 受控打开 / 挂载即展开的浮层根本没分配 z-index;
+ * 3. Drawer 的 wrap 是 fixed 但没 z-index → 整个抽屉被自己的遮罩压住。
+ *
+ * 注意:playground 里有两个「挂载即展开」的常驻 Popover,
+ * 所以定位浮层时要用 :has-text / filter 精确命中,不能直接 `.g3-popover__popup`。
+ */
+
+/** 读取视口内某点最上层的元素类名(判断「谁盖住谁」的唯一可靠方式) */
+async function topElementAt(page: import('@playwright/test').Page, x: number, y: number) {
+ return page.evaluate(
+ ([px, py]) => {
+ const el = document.elementFromPoint(px as number, py as number)
+ if (!el) return null
+ return typeof el.className === 'string' ? el.className : el.tagName
+ },
+ [x, y],
+ )
+}
+
+test.describe('浮层层级', () => {
+ test('挂载即展开的浮层同样有层级,且同档按打开顺序递增', async ({ page }) => {
+ await page.goto('/')
+
+ const popupA = page.locator('.g3-popover__popup', { hasText: '浮层 A' })
+ const popupB = page.locator('.g3-popover__popup', { hasText: '浮层 B' })
+ await expect(popupA).toBeVisible()
+ await expect(popupB).toBeVisible()
+
+ const zA = Number(await popupA.evaluate((el) => (el as HTMLElement).style.zIndex))
+ const zB = Number(await popupB.evaluate((el) => (el as HTMLElement).style.zIndex))
+
+ expect(zA).toBeGreaterThan(3000)
+ expect(zB).toBeGreaterThan(zA)
+ })
+
+ test('抽屉面板不会被自身遮罩盖住,点遮罩仍可关闭', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-drawer').click()
+
+ const drawer = page.locator('.g3-drawer')
+ await expect(drawer).toBeVisible()
+ // 等入场动画结束(transform 归零)再做命中测试,否则面板还在视口外
+ await page.waitForTimeout(450)
+
+ const panelBox = (await drawer.boundingBox())!
+ const inside = await topElementAt(
+ page,
+ panelBox.x + panelBox.width / 2,
+ panelBox.y + panelBox.height / 2,
+ )
+ expect(inside).not.toBeNull()
+ expect(inside).toContain('g3-drawer')
+
+ // 遮罩与 wrap 同级(同一实例共用层级)
+ const maskZ = Number(await page.$eval('.g3-drawer__mask', (el) => (el as HTMLElement).style.zIndex))
+ const wrapZ = Number(await page.$eval('.g3-drawer__wrap', (el) => (el as HTMLElement).style.zIndex))
+ expect(wrapZ).toBe(maskZ)
+
+ // 点面板外(遮罩区域)关闭
+ const maskBox = (await page.locator('.g3-drawer__mask').boundingBox())!
+ await page.mouse.click(maskBox.x + 40, maskBox.y + maskBox.height / 2)
+ await expect(drawer).toBeHidden()
+ })
+
+ test('弹窗内的下拉浮层高于遮罩,且可正常点选', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-modal').click()
+ await expect(page.locator('.g3-modal')).toBeVisible()
+
+ const select = page.getByTestId('modal-select')
+ await select.locator('.g3-select__trigger').click()
+
+ const popup = page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') })
+ await expect(popup).toBeVisible()
+
+ const modalZ = Number(await page.$eval('.g3-modal__mask', (el) => (el as HTMLElement).style.zIndex))
+ const popupZ = Number(await popup.evaluate((el) => (el as HTMLElement).style.zIndex))
+ expect(popupZ).toBeGreaterThan(modalZ)
+
+ // 浮层区域最上层必须是选项本体,而不是遮罩
+ const optionBox = (await page.locator('.g3-select__option').first().boundingBox())!
+ const hit = await topElementAt(page, optionBox.x + 10, optionBox.y + optionBox.height / 2)
+ expect(hit).toContain('g3-select__option')
+ })
+
+ test('反馈类提示盖在浮层与弹窗之上', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('open-modal').click()
+ const select = page.getByTestId('modal-select')
+ await select.locator('.g3-select__trigger').click()
+ await expect(
+ page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') }),
+ ).toBeVisible()
+
+ // 触发按钮此时在弹窗遮罩之后:真实用户点不到它(这正是层级正确的表现),
+ // 所以直接派发 click 到元素上,本用例只验证「提示出现后压在最上层」
+ await page.getByTestId('toast-default').dispatchEvent('click')
+ await expect(page.locator('.g3-message').first()).toBeVisible()
+
+ const messageZ = Number(
+ await page.$eval('.g3-message__container', (el) => (el as HTMLElement).style.zIndex),
+ )
+ const popupZ = Number(
+ await page
+ .locator('.g3-popover__popup')
+ .filter({ has: page.locator('.g3-select__dropdown') })
+ .evaluate((el) => (el as HTMLElement).style.zIndex),
+ )
+ const modalZ = Number(await page.$eval('.g3-modal__mask', (el) => (el as HTMLElement).style.zIndex))
+
+ expect(messageZ).toBeGreaterThan(popupZ)
+ expect(popupZ).toBeGreaterThan(modalZ)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/select.spec.ts b/code/g3soft-libs/packages/ui/e2e/select.spec.ts
new file mode 100644
index 00000000..9240b74a
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/select.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * Select 回归。
+ *
+ * 最严重的一次事故:unwrapped 触发器每次渲染都换新的组件对象,
+ * `` 判定类型变化 → 卸载重建触发器 DOM → 焦点丢失派发 focusout
+ * → 处理器改状态 → 再重建……自我驱动成死循环,点击一下就把主线程打满。
+ * 所以这里用 MutationObserver 数「DOM 节点增删次数」来锁住「触发器不被重建」。
+ */
+
+test.describe('Select 交互', () => {
+ test('打开下拉不会重建触发器 DOM(死循环回归)', async ({ page }) => {
+ await page.goto('/')
+
+ await page.evaluate(() => {
+ ;(window as unknown as { __mut: number }).__mut = 0
+ new MutationObserver((records) => {
+ const target = window as unknown as { __mut: number }
+ for (const record of records) {
+ target.__mut += record.addedNodes.length + record.removedNodes.length
+ }
+ }).observe(document.body, { childList: true, subtree: true })
+ })
+
+ const trigger = page.locator('[data-testid="select-basic"] .g3-select__trigger')
+ await trigger.click()
+ await expect(page.locator('.g3-select__option').first()).toBeVisible()
+ await page.waitForTimeout(400)
+
+ const mutations = await page.evaluate(() => (window as unknown as { __mut: number }).__mut)
+ // 只有浮层挂载带来的少量节点变化;重建触发器会产生成百上千次
+ expect(mutations).toBeLessThan(20)
+
+ // 焦点应稳定停留在触发器输入框上
+ const focused = await page.evaluate(() => document.activeElement?.className ?? '')
+ expect(focused).toContain('g3-select__input')
+ })
+
+ test('选中选项后回填并关闭', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="select-basic"]')
+
+ await root.locator('.g3-select__trigger').click()
+ await page.locator('.g3-select__option').filter({ hasText: '会员' }).click()
+
+ await expect(root.locator('input')).toHaveValue('会员')
+ await expect(page.locator('.g3-select__dropdown')).toHaveCount(0)
+ })
+
+ test('多选以「、」拼接回显,且选中后保持展开', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="select-multiple"]')
+
+ await root.locator('.g3-select__trigger').click()
+ await page.locator('.g3-select__option').filter({ hasText: '管理员' }).click()
+ await page.locator('.g3-select__option').filter({ hasText: '会员' }).click()
+
+ await expect(root.locator('input')).toHaveValue('管理员、会员')
+ await expect(page.locator('.g3-select__dropdown')).toBeVisible()
+ })
+
+ test('下拉宽度跟随触发框,且不超过上限', async ({ page }) => {
+ await page.goto('/')
+ const root = page.locator('[data-testid="select-basic"]')
+ const triggerWidth = (await root.locator('.g3-select__trigger').boundingBox())!.width
+
+ await root.locator('.g3-select__trigger').click()
+ // playground 里还有两个常驻 Popover,用内容过滤精确定位本用例的浮层
+ const popup = page.locator('.g3-popover__popup').filter({ has: page.locator('.g3-select__dropdown') })
+ await expect(popup).toBeVisible()
+
+ const popupWidth = (await popup.boundingBox())!.width
+ // 下拉宽度 = max(触发框宽, 内容宽):触发框更宽时下拉跟着变宽(antd 行为),
+ // 样式里的 max-width 只在触发框较窄、内容很长时起限制作用
+ expect(popupWidth).toBeGreaterThanOrEqual(Math.floor(triggerWidth) - 1)
+
+ const viewport = page.viewportSize()!
+ expect(popupWidth).toBeLessThanOrEqual(viewport.width)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/e2e/ui-regressions.spec.ts b/code/g3soft-libs/packages/ui/e2e/ui-regressions.spec.ts
new file mode 100644
index 00000000..221b418a
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/e2e/ui-regressions.spec.ts
@@ -0,0 +1,224 @@
+import { expect, test } from '@playwright/test'
+
+/**
+ * 组件细节回归 —— 每一条都对应一个真实报障,之前全部漏测。
+ *
+ * 共同点:它们要么靠布局计算(滑块尺寸、等高、线性位移),要么靠伪类层叠
+ * (:hover 的特异性、相邻边框的 z-index),要么靠 CSS 变量的实际解析结果
+ * (color-mix 里的 var())。这些在 happy-dom 里量不出来,必须放 E2E。
+ */
+
+test.describe('ButtonGroup', () => {
+ test('横向:hover 的按钮浮到上层,被压住的那条边框仍可见', async ({ page }) => {
+ await page.goto('/')
+ const second = page.getByTestId('btn-group-plain').locator('.g3-button').nth(1)
+ await second.hover()
+
+ // 相邻按钮用 -1px 负边距重叠,不抬 z-index 时后一个按钮的边框会盖住它
+ await expect(second).toHaveCSS('z-index', '1')
+ // 边框色有 0.2s 过渡:必须用会重试的断言,直接 evaluate 会读到过渡中间值
+ await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)')
+ })
+
+ test('纵向:hover 的按钮同样抬升', async ({ page }) => {
+ await page.goto('/')
+ const second = page.getByTestId('btn-group-vertical').locator('.g3-button').nth(1)
+ await second.hover()
+
+ await expect(second).toHaveCSS('z-index', '1')
+ await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)')
+ })
+})
+
+test.describe('Segmented', () => {
+ test('选中滑块与选中项同尺寸同位置(滑块曾塌成 0 高,看不出选中)', async ({ page }) => {
+ await page.goto('/')
+ // 必须限定在同一个实例内:页面上有多组 Segmented,跨实例比较会拿到别人的滑块
+ const group = page.locator('.g3-segmented__group').first()
+ const thumb = group.locator('.g3-segmented__thumb')
+ const item = group.locator('.g3-segmented__item.is-selected')
+
+ const t = (await thumb.boundingBox())!
+ const i = (await item.boundingBox())!
+
+ // 滑块尺寸来自 offsetWidth/offsetHeight(整数),实际布局是亚像素,允许 1px 误差
+ for (const [name, a, b] of [
+ ['width', t.width, i.width],
+ ['height', t.height, i.height],
+ ['x', t.x, i.x],
+ ['y', t.y, i.y],
+ ] as const) {
+ expect(Math.abs(a - b), `滑块与选中项的 ${name} 不一致`).toBeLessThanOrEqual(1)
+ }
+ expect(t.height).toBeGreaterThan(0)
+ // 滑块就是选中底色,不能是透明的
+ expect(await thumb.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(
+ 'rgb(255, 255, 255)',
+ )
+ })
+})
+
+test.describe('Pagination', () => {
+ test('hover 当前页仍保持主色填充(曾被通用 hover 覆盖成白底白字)', async ({ page }) => {
+ await page.goto('/')
+ const current = page.locator('.g3-pagination__page.is-page-current')
+ await current.hover()
+
+ await expect(current).toHaveCSS('background-color', 'rgb(24, 24, 27)')
+ await expect(current).toHaveCSS('color', 'rgb(250, 250, 250)')
+ })
+})
+
+test.describe('Tooltip', () => {
+ test('气泡没有描边(投影由 popup 的 drop-shadow 统一施加)', async ({ page }) => {
+ await page.goto('/')
+ await page.getByTestId('tooltip-trigger').hover()
+
+ const content = page.locator('.g3-popover__content').first()
+ await expect(content).toBeVisible()
+ await expect(content).toHaveCSS('border-top-width', '0px')
+ await expect(content).toHaveCSS('border-left-width', '0px')
+ })
+})
+
+test.describe('Tabs 垂直方向', () => {
+ for (const placement of ['left', 'right'] as const) {
+ test(`${placement}:标签栏与内容区之间有竖向分隔线,选中项带侧边指示线与底色`, async ({
+ page,
+ }) => {
+ await page.goto('/')
+ const tabs = page.locator(`.g3-tabs--${placement}`).first()
+ const nav = tabs.locator('.g3-tabs__nav')
+ const active = tabs.locator('.g3-tabs__tab.is-tab-active').first()
+
+ if (placement === 'left') {
+ await expect(nav).toHaveCSS('border-right-width', '1px')
+ await expect(active).toHaveCSS('border-right-width', '2px')
+ } else {
+ await expect(nav).toHaveCSS('border-left-width', '1px')
+ await expect(active).toHaveCSS('border-left-width', '2px')
+ }
+ // 选中项底色不再是 transparent
+ const bg = await active.evaluate((el) => getComputedStyle(el).backgroundColor)
+ expect(bg).not.toBe('rgba(0, 0, 0, 0)')
+ })
+ }
+})
+
+test.describe('Tree', () => {
+ test('点击节点有选中底色(token color-mix 曾解析失败导致背景透明)', async ({ page }) => {
+ await page.goto('/')
+ const node = page.locator('.g3-tree-node', { hasText: '节点一' }).first()
+ await node.click()
+
+ await expect(node).toHaveClass(/is-selected/)
+ const bg = await node.evaluate((el) => getComputedStyle(el).backgroundColor)
+ expect(bg, `选中底色不应为透明:${bg}`).not.toBe('rgba(0, 0, 0, 0)')
+ })
+
+ test('整层都是叶子时不渲染展开箭头占位', async ({ page }) => {
+ await page.goto('/')
+ // 扁平树:两个节点都没有子节点
+ const flatTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' }).first()
+
+ await expect(flatTree.locator('.g3-tree-node')).toHaveCount(2)
+ await expect(flatTree.locator('.g3-tree-node__switcher')).toHaveCount(0)
+ })
+
+ test('拖拽需要长按武装:直接拖动不会产生落点', async ({ page }) => {
+ await page.goto('/')
+ const draggableTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' })
+ // 用带 draggable 的那棵树里的第一行
+ const row = page.locator('.g3-tree-node').first()
+ const box = (await row.boundingBox())!
+
+ await page.mouse.move(box.x + 40, box.y + box.height / 2)
+ await page.mouse.down()
+ // 立刻移动(未到 400ms 阈值):draggable 属性还没武装
+ await page.mouse.move(box.x + 60, box.y + box.height / 2 + 30, { steps: 3 })
+ expect(await row.getAttribute('draggable')).toBeNull()
+ await page.mouse.up()
+ expect(await draggableTree.count()).toBeGreaterThan(0)
+ })
+})
+
+test.describe('DatePicker 时间列', () => {
+ test('时/分/秒三列并排:定宽、列间分隔线、列表与日历等高且可滚动', async ({ page }) => {
+ await page.goto('/')
+ await page.locator('.g3-date-picker__input').first().click()
+
+ const calendar = page.locator('.g3-calendar')
+ await expect(calendar).toBeVisible()
+
+ const cols = calendar.locator('.g3-time-select__col')
+ await expect(cols).toHaveCount(3)
+ await expect(cols.first()).toHaveCSS('width', '56px')
+ await expect(cols.nth(1)).toHaveCSS('border-left-width', '1px')
+
+ const main = (await calendar.locator('.g3-calendar__main').boundingBox())!
+ const time = (await calendar.locator('.g3-calendar__time').boundingBox())!
+ expect(Math.round(main.height)).toBe(Math.round(time.height))
+
+ const list = calendar.locator('.g3-time-select__list').first()
+ const listBox = (await list.boundingBox())!
+ expect(Math.round(listBox.height)).toBe(Math.round(main.height))
+ expect(
+ await list.evaluate((el) => el.scrollHeight > el.clientHeight),
+ '时间列应当内部滚动而不是撑开面板',
+ ).toBe(true)
+ // 单个选项 28px 高
+ await expect(calendar.locator('.g3-time-select__item').first()).toHaveCSS('height', '28px')
+ })
+
+ test('年 / 月选择面板:3 列 × 4 行,标题显示年份区间,当前年有标记', async ({ page }) => {
+ await page.goto('/')
+ await page.locator('.g3-date-picker__input').first().click()
+
+ await page.locator('.g3-calendar__title-button').first().click()
+
+ const selector = page.locator('.g3-calendar__selector')
+ await expect(selector).toBeVisible()
+ await expect(selector.locator('.g3-calendar__selector-item')).toHaveCount(12)
+ await expect(selector).toHaveCSS('grid-template-columns', /(\d+(\.\d+)?px ){2}/)
+
+ const title = await page.locator('.g3-calendar__title-button').first().textContent()
+ expect(title).toMatch(/\d{4}\s*-\s*\d{4}/)
+ await expect(selector.locator('.g3-calendar__selector-item.is-selected')).toHaveCount(1)
+ })
+})
+
+test.describe('Splitter 拖拽', () => {
+ test('面板宽度随指针线性变化(曾经一拖就跳)', async ({ page }) => {
+ await page.goto('/')
+ const bar = page.locator('.g3-splitter__bar').first()
+ // mouse API 用视口坐标且不会自动滚动,元素不在视口内时拖拽会落空
+ await bar.scrollIntoViewIfNeeded()
+ const box = (await bar.boundingBox())!
+ const widths = () =>
+ page.$$eval('.g3-splitter-panel', (els) =>
+ els.map((el) => Math.round(el.getBoundingClientRect().width)),
+ )
+
+ const before = await widths()
+ const startX = box.x + box.width / 2
+ const y = box.y + box.height / 2
+
+ await page.mouse.move(startX, y)
+ await page.mouse.down()
+
+ const samples: number[][] = []
+ for (const dx of [20, 40, 60]) {
+ await page.mouse.move(startX + dx, y)
+ await page.waitForTimeout(60)
+ samples.push(await widths())
+ }
+ await page.mouse.up()
+
+ // 每移动 20px,左侧面板就该宽 20px(严格 1:1,不是跳到某个值)
+ samples.forEach((sample, index) => {
+ const delta = sample[0] - before[0]
+ expect(delta, `第 ${index + 1} 次移动后左侧宽度增量`).toBeGreaterThanOrEqual((index + 1) * 20 - 2)
+ expect(delta).toBeLessThanOrEqual((index + 1) * 20 + 2)
+ })
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/package.json b/code/g3soft-libs/packages/ui/package.json
index c06c173a..ab208c97 100644
--- a/code/g3soft-libs/packages/ui/package.json
+++ b/code/g3soft-libs/packages/ui/package.json
@@ -21,20 +21,33 @@
},
"scripts": {
"build": "vite build",
- "prepublishOnly": "publint"
+ "prepublishOnly": "publint",
+ "playground": "vite --config playground/vite.config.ts",
+ "test": "vitest run",
+ "test:unit": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "playwright test",
+ "test:e2e:headed": "playwright test --headed",
+ "test:e2e:report": "playwright show-report",
+ "verify": "pnpm build && pnpm test:unit && pnpm test:e2e"
},
"peerDependencies": {
"vue": "^3.5.0"
},
"dependencies": {
- "@g3soft/tokens": "workspace:*"
+ "@g3soft/tokens": "workspace:*",
+ "@lucide/vue": "^1.31.0"
},
"devDependencies": {
+ "@playwright/test": "^1.63.0",
"@vitejs/plugin-vue": "^6.0.0",
+ "@vue/test-utils": "^2.5.1",
+ "happy-dom": "^20.14.5",
"publint": "latest",
"sass": "catalog:",
"vite": "^8.0.0",
"vite-plugin-dts": "latest",
+ "vitest": "^5.0.3",
"vue": "catalog:",
"vue-tsc": "^3.0.0"
},
diff --git a/code/g3soft-libs/packages/ui/playground/index.html b/code/g3soft-libs/packages/ui/playground/index.html
new file mode 100644
index 00000000..94743e23
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playground/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+ G3 组件 playground
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/playground/main.ts b/code/g3soft-libs/packages/ui/playground/main.ts
new file mode 100644
index 00000000..f4be49ab
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playground/main.ts
@@ -0,0 +1,420 @@
+import { createApp, reactive, ref } from 'vue'
+import { Plus, Search, Settings } from '@lucide/vue'
+import {
+ G3Button,
+ G3ButtonGroup,
+ G3ButtonGroupSeparator,
+ G3Checkbox,
+ G3Col,
+ G3DatePicker,
+ G3Drawer,
+ G3Dropdown,
+ G3DropdownItem,
+ G3Form,
+ G3FormGroup,
+ G3FormItem,
+ G3Input,
+ G3Message,
+ G3Modal,
+ G3Notification,
+ G3Pagination,
+ G3Popover,
+ G3Radio,
+ G3RadioGroup,
+ G3RangePicker,
+ G3Row,
+ G3Scrollbar,
+ G3Segmented,
+ G3Select,
+ G3Space,
+ G3Spin,
+ G3Splitter,
+ G3SplitterPanel,
+ G3Switch,
+ G3TabPane,
+ G3Tabs,
+ G3Tag,
+ G3TimeSelect,
+ G3Tooltip,
+ G3Tree,
+} from '@g3soft/ui'
+import '@g3soft/tokens'
+
+/**
+ * playground —— 组件在真实浏览器里的落脚点。
+ *
+ * 约定:所有 E2E 与人工手测都通过 `data-testid` 找元素,
+ * 不要依赖文本或层级结构(文案会改、结构会调,testid 保持稳定)。
+ */
+
+const ROLE_OPTIONS = [
+ { label: '管理员', value: 1 },
+ { label: '会员', value: 2 },
+ { label: '访客', value: 3 },
+]
+
+const TREE_DATA = [
+ {
+ key: '1',
+ title: '前端',
+ children: [
+ { key: '1-1', title: 'Vue', children: [{ key: '1-1-1', title: '组合式 API' }] },
+ { key: '1-2', title: 'React' },
+ ],
+ },
+ { key: '2', title: '后端', children: [{ key: '2-1', title: 'Node.js' }] },
+]
+
+const LONG_TEXT = Array.from({ length: 40 }, (_, index) => `第 ${index + 1} 行内容`).join('\n')
+
+const App = {
+ components: {
+ G3Button,
+ G3ButtonGroup,
+ G3ButtonGroupSeparator,
+ G3Checkbox,
+ G3Col,
+ G3DatePicker,
+ G3Drawer,
+ G3Dropdown,
+ G3DropdownItem,
+ G3Form,
+ G3FormGroup,
+ G3FormItem,
+ G3Input,
+ G3Modal,
+ G3Pagination,
+ G3Popover,
+ G3Radio,
+ G3RadioGroup,
+ G3RangePicker,
+ G3Row,
+ G3Scrollbar,
+ G3Segmented,
+ G3Select,
+ G3Space,
+ G3Spin,
+ G3Splitter,
+ G3SplitterPanel,
+ G3Switch,
+ G3TabPane,
+ G3Tabs,
+ G3Tag,
+ G3TimeSelect,
+ G3Tooltip,
+ G3Tree,
+ Plus,
+ Search,
+ Settings,
+ },
+ setup() {
+ const model = reactive({
+ name: '张三',
+ intro: '这是多行文本',
+ role: 1 as number | undefined,
+ city: [] as number[],
+ agree: true,
+ level: 2,
+ gender: 'male',
+ date: '2026-10-08 09:30:00',
+ range: ['2026-10-01', '2026-10-08'] as string[],
+ })
+ const tab = ref('a')
+ // 选项是中文原始值,初始值必须对上,否则没有选中项
+ const seg = ref('日')
+ const time = ref(new Date(2026, 9, 8, 9, 30, 0))
+ const modalOpen = ref(false)
+ const modalConfirmOpen = ref(false)
+ const drawerOpen = ref(false)
+ // 四个方向都要能验:上下面板的宽度必须撑满视口(曾因用 flex 对齐而缩到内容宽)
+ const drawerPlacement = ref<'left' | 'right' | 'top' | 'bottom'>('right')
+ // 挂载即展开的浮层:验证「初始 open」也能拿到 z-index
+ const popoverAOpen = ref(true)
+ const popoverBOpen = ref(true)
+
+ // 扁平树:两个节点都不是父节点,用来验「叶子不该留展开箭头占位」
+ const flatTreeData = [
+ { key: 'flat-1', title: '节点一(无子节点)' },
+ { key: 'flat-2', title: '节点二(无子节点)' },
+ ]
+ const treeMenuItems = [
+ { key: 'add', label: '新增子节点' },
+ { key: 'rename', label: '重命名' },
+ { key: 'delete', label: '删除', danger: true },
+ ]
+
+ function toast(placement?: string) {
+ G3Message.success(`操作成功`, placement ? { placement: placement as never } : undefined)
+ }
+
+ function notify() {
+ G3Notification.info({ title: '定时任务完成', description: '本次共同步 128 条数据。' })
+ }
+
+ function openDrawer(placement: 'left' | 'right' | 'top' | 'bottom') {
+ drawerPlacement.value = placement
+ drawerOpen.value = true
+ }
+
+ function openCommandConfirm() {
+ G3Modal.confirm({
+ title: '删除确认',
+ content: '确定要删除这条记录吗?',
+ onConfirm: () => new Promise((resolve) => setTimeout(resolve, 600)),
+ })
+ }
+
+ return {
+ model,
+ tab,
+ seg,
+ time,
+ modalOpen,
+ modalConfirmOpen,
+ drawerOpen,
+ drawerPlacement,
+ openDrawer,
+ popoverAOpen,
+ popoverBOpen,
+ flatTreeData,
+ treeMenuItems,
+ roleOptions: ROLE_OPTIONS,
+ treeData: TREE_DATA,
+ longText: LONG_TEXT,
+ toast,
+ notify,
+ openCommandConfirm,
+ }
+ },
+ template: `
+
+
+ 基础:Button / Tag / Spin / Segmented / Space
+
+
主要
+
次要
+
描边
+
幽灵
+
危险
+
危险描边
+
链接
+
+
小
+
大
+
加载中
+
禁用
+
+
+
+ 左
+
+ 中
+
+ 右
+
+
+
+ 第一
+ 第二
+ 第三
+
+
+ 上
+ 中
+ 下
+
+ default
+ secondary
+ outline
+ success
+ danger
+ ghost
+ 可关闭
+
+
+
+
+ 间距测试
+
+
+
+
+
+
+
+
+ Tabs / Tree / Pagination / Splitter / Scrollbar / TimeSelect
+
+
+
+
+
+ 标签一的内容
+ 标签二的内容
+ 不可见
+
+
+
+ 左侧标签页内容
+ 第二个面板
+
+
+ 右侧标签页内容
+ 第二个面板
+
+
+
+
+
+
+
+
+
+
+
+
+ 左
+ 右
+
+
+
+ {{ longText }}
+
+
+
+
+
+ Dropdown / Tooltip / Popover(首两个挂载即展开,用于层级回归)
+
+
+ 下拉菜单
+ 编辑
+ 复制
+
+ 删除
+
+
+ 悬停提示
+
+
+ 初始展开 A
+ 浮层 A
+
+
+ 初始展开 B
+ 浮层 B
+
+
+
+
+
+ Modal / Drawer / Message / Notification
+
+ 打开 Modal
+ 右侧 Drawer
+ 左侧 Drawer
+ 上方 Drawer
+ 下方 Drawer
+ Modal.confirm
+ Message 默认
+ Message 左上
+ Message 右下
+ Notification
+
+
+
+ 第一段内容。
+
+ 第 {{ i }} 段内容,用于验证缩放时内容区是否内部滚动。
+
+
+
+ 抽屉内容
+
+
+
+
+ `,
+}
+
+createApp(App).mount('#app')
diff --git a/code/g3soft-libs/packages/ui/playground/vite.config.ts b/code/g3soft-libs/packages/ui/playground/vite.config.ts
new file mode 100644
index 00000000..45076441
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playground/vite.config.ts
@@ -0,0 +1,45 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vite'
+
+/**
+ * 组件 playground —— E2E(Playwright)与人工/ AI 手测共用的宿主页面。
+ *
+ * 只有两个用途:把组件跑在真实浏览器里、给每个可交互元素一个稳定的 data-testid。
+ * 不参与打包(files 只发布 dist),也不影响组件库构建。
+ */
+
+const configScssPath = fileURLToPath(new URL('../../tokens/src/scss/_config.scss', import.meta.url))
+const configScss = readFileSync(configScssPath, 'utf-8')
+
+function readScssDefault(name: string, fallback: string): string {
+ const match = configScss.match(new RegExp(`\\$${name}\\s*:\\s*['"]([^'"]+)['"]\\s*!default`))
+ return match ? match[1] : fallback
+}
+
+export default defineConfig({
+ root: fileURLToPath(new URL('.', import.meta.url)),
+ plugins: [vue()],
+ resolve: {
+ alias: [
+ {
+ find: /^@g3soft\/ui$/,
+ replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
+ },
+ {
+ find: /^@g3soft\/tokens$/,
+ replacement: fileURLToPath(new URL('../../tokens/dist/index.css', import.meta.url)),
+ },
+ { find: /^vue$/, replacement: 'vue/dist/vue.esm-bundler.js' },
+ ],
+ },
+ css: {
+ preprocessorOptions: { scss: { api: 'modern-compiler' } },
+ },
+ define: {
+ __G3_NAMESPACE__: JSON.stringify(readScssDefault('namespace', 'g3')),
+ __G3_STATE_PREFIX__: JSON.stringify(readScssDefault('state-prefix', 'is')),
+ },
+ server: { port: 5200, strictPort: true },
+})
diff --git a/code/g3soft-libs/packages/ui/playwright.config.ts b/code/g3soft-libs/packages/ui/playwright.config.ts
new file mode 100644
index 00000000..3bcaf045
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/playwright.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig, devices } from '@playwright/test'
+
+/**
+ * E2E(L3)—— 只用来覆盖单测跑不了的部分:
+ * 真实布局(弹层定位 / 层叠上下文)、命中测试、拖拽、焦点陷阱、DOM 重建检测。
+ *
+ * 面向工具/AI 的设计:
+ * - `webServer` 自动拉起 playground,不需要手工起服务
+ * - 失败自动留截图 + trace + video,产物在 test-results/
+ * - json reporter 输出 test-results/e2e.json,便于脚本读取与二次分析
+ */
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 30_000,
+ expect: { timeout: 5_000 },
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: [
+ ['list'],
+ ['json', { outputFile: 'test-results/e2e.json' }],
+ ],
+ use: {
+ baseURL: 'http://localhost:5200',
+ // 失败留证据:截图定位视觉问题、trace 可下载后在浏览器里逐步回放
+ // (刻意不开 video:体积大,且每次启动清理产物容易撞到 IDE 的批量删除守卫)
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'off',
+ viewport: { width: 1280, height: 900 },
+ },
+ webServer: {
+ command: 'pnpm exec vite --config playground/vite.config.ts',
+ url: 'http://localhost:5200',
+ reuseExistingServer: true,
+ timeout: 90_000,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+})
diff --git a/code/g3soft-libs/packages/ui/src/_composables/useComponentDefaults.ts b/code/g3soft-libs/packages/ui/src/_composables/useComponentDefaults.ts
new file mode 100644
index 00000000..2fa3c322
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_composables/useComponentDefaults.ts
@@ -0,0 +1,34 @@
+import { computed, type ComputedRef } from 'vue'
+import { useConfig } from '../config-provider/context'
+import { getGlobalConfig } from '../_utils/global-config'
+
+/**
+ * 按组件维度读取全局默认 props。
+ *
+ * 回落顺序:**显式传入的 prop > ConfigProvider.componentDefaults[组件名] > withDefaults 内置默认**。
+ * 因此需要支持全局覆盖的 prop,其 `withDefaults` 默认值必须写 `undefined`——
+ * 否则「未传」与「显式传了默认值」无法区分,全局配置永远不生效。
+ *
+ * 组件内用法:
+ * ```ts
+ * const merged = useComponentDefaults('button', props)
+ * // 模板里一律读 merged.value.xxx
+ * ```
+ */
+export function useComponentDefaults>(
+ componentName: string,
+ props: T,
+): ComputedRef {
+ const config = useConfig()
+ return computed(() => {
+ const defaults =
+ config?.componentDefaults.value?.[componentName] ??
+ getGlobalConfig().componentDefaults?.[componentName]
+ if (!defaults) return props
+ const merged: Record = { ...props }
+ for (const [key, value] of Object.entries(defaults)) {
+ if (merged[key] === undefined) merged[key] = value
+ }
+ return merged as T
+ })
+}
diff --git a/code/g3soft-libs/packages/ui/src/_composables/useFloating.ts b/code/g3soft-libs/packages/ui/src/_composables/useFloating.ts
new file mode 100644
index 00000000..dc151d33
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_composables/useFloating.ts
@@ -0,0 +1,162 @@
+import {
+ computed,
+ nextTick,
+ onBeforeUnmount,
+ ref,
+ watch,
+ type CSSProperties,
+ type ComputedRef,
+ type Ref,
+} from 'vue'
+import { computePosition, type Placement, type RectLike } from '../_utils/position'
+
+export interface UseFloatingOptions {
+ /** 是否展开 */
+ open: Ref | ComputedRef
+ /** 期望方位 */
+ placement: () => Placement
+ /** 与触发器的间距 */
+ offset?: () => number
+ /** 浮层宽度是否以触发器宽度为下限 */
+ followTriggerWidth?: () => boolean
+ /** 触发器元素(定位锚点) */
+ triggerRef: Ref
+ /** 打开状态下的锚点覆盖(如 contextmenu 以指针位置为锚点) */
+ anchor?: () => RectLike | null
+ /** 打开时点击触发器与浮层之外的回调 */
+ onClickOutside?: () => void
+ /** 打开时按 Esc 的回调 */
+ onEscape?: () => void
+}
+
+export interface UseFloatingReturn {
+ /** 浮层元素 */
+ floatingRef: Ref
+ /** 浮层定位样式(left / top) */
+ floatingStyle: Ref
+ /** 翻转 / 位移后实际生效的方位 */
+ resolvedPlacement: Ref
+ /** 实际方位的主方向(top / bottom / left / right),供箭头吸附使用 */
+ resolvedBase: ComputedRef
+ /** 立即重算位置 */
+ updatePosition: () => void
+}
+
+/**
+ * 浮层通用能力:定位、跟随重算、点击外部与 Esc 关闭。
+ *
+ * 定位是纯函数(见 _utils/position.ts),这里只负责:取 rect、写 style、
+ * 在打开期间监听 scroll / resize(rAF 合并)与浮层尺寸变化(ResizeObserver)。
+ * Teleport 容器、触发器事件、展开状态由组件层自己管理。
+ */
+export function useFloating(options: UseFloatingOptions): UseFloatingReturn {
+ const floatingRef = ref(null)
+ const resolvedPlacement = ref(options.placement())
+ const floatingStyle = ref({})
+
+ let rafId: number | null = null
+ let resizeObserver: ResizeObserver | null = null
+
+ const resolvedBase = computed(() => resolvedPlacement.value.split('-')[0])
+
+ function updatePosition(): void {
+ const trigger = options.triggerRef.value
+ const floating = floatingRef.value
+ if (!trigger || !floating) return
+ // 跟随触发器宽度:只作为浮层宽度下限(min-width),内容更宽时按内容撑开。
+ // 直接写 DOM 而不经过 :style 绑定,紧随其后的 offsetWidth 才能读到下限生效后的真实宽度;
+ // 值没变就不写,避免在下方的 ResizeObserver 回调里反复触发样式重算。
+ const minWidth = options.followTriggerWidth?.() === true ? `${trigger.offsetWidth}px` : ''
+ if (floating.style.minWidth !== minWidth) floating.style.minWidth = minWidth
+ const anchor = options.anchor?.() ?? null
+ const triggerRect = anchor ?? trigger.getBoundingClientRect()
+ const { x, y, placement } = computePosition({
+ placement: options.placement(),
+ triggerRect,
+ floatSize: { width: floating.offsetWidth, height: floating.offsetHeight },
+ offset: options.offset?.() ?? 0,
+ })
+ resolvedPlacement.value = placement
+ floatingStyle.value = { left: `${x}px`, top: `${y}px` }
+ }
+
+ /** 滚动 / 缩放跟随:rAF 合并高频触发,避免 layout thrashing。 */
+ function scheduleUpdate(): void {
+ if (!options.open.value || rafId !== null) return
+ rafId = requestAnimationFrame(() => {
+ rafId = null
+ updatePosition()
+ })
+ }
+
+ function cancelFrame(): void {
+ if (rafId === null) return
+ cancelAnimationFrame(rafId)
+ rafId = null
+ }
+
+ function startResizeObserver(): void {
+ if (typeof ResizeObserver === 'undefined' || !floatingRef.value) return
+ resizeObserver?.disconnect()
+ resizeObserver = new ResizeObserver(scheduleUpdate)
+ resizeObserver.observe(floatingRef.value)
+ }
+
+ function stopResizeObserver(): void {
+ resizeObserver?.disconnect()
+ resizeObserver = null
+ }
+
+ function onDocumentPointerDown(event: PointerEvent): void {
+ if (!options.open.value) return
+ const target = event.target as Node
+ const inTrigger = options.triggerRef.value?.contains(target) === true
+ const inFloating = floatingRef.value?.contains(target) === true
+ if (!inTrigger && !inFloating) options.onClickOutside?.()
+ }
+
+ function onDocumentKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Escape' && options.open.value) options.onEscape?.()
+ }
+
+ function bindGlobal(): void {
+ document.addEventListener('pointerdown', onDocumentPointerDown, true)
+ document.addEventListener('keydown', onDocumentKeydown)
+ window.addEventListener('resize', scheduleUpdate)
+ window.addEventListener('scroll', scheduleUpdate, true)
+ }
+
+ function unbindGlobal(): void {
+ document.removeEventListener('pointerdown', onDocumentPointerDown, true)
+ document.removeEventListener('keydown', onDocumentKeydown)
+ window.removeEventListener('resize', scheduleUpdate)
+ window.removeEventListener('scroll', scheduleUpdate, true)
+ }
+
+ watch(
+ options.open,
+ (value) => {
+ if (value) {
+ bindGlobal()
+ nextTick(() => {
+ if (!options.open.value) return
+ updatePosition()
+ startResizeObserver()
+ })
+ } else {
+ unbindGlobal()
+ cancelFrame()
+ stopResizeObserver()
+ }
+ },
+ { immediate: true },
+ )
+
+ onBeforeUnmount(() => {
+ unbindGlobal()
+ cancelFrame()
+ stopResizeObserver()
+ })
+
+ return { floatingRef, floatingStyle, resolvedPlacement, resolvedBase, updatePosition }
+}
diff --git a/code/g3soft-libs/packages/ui/src/_composables/usePopupContainer.ts b/code/g3soft-libs/packages/ui/src/_composables/usePopupContainer.ts
new file mode 100644
index 00000000..53916fb8
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_composables/usePopupContainer.ts
@@ -0,0 +1,35 @@
+import { computed, type ComputedRef } from 'vue'
+import { useConfig } from '../config-provider/context'
+import { getGlobalConfig, type PopupContainer } from '../_utils/global-config'
+
+/**
+ * 解析弹层挂载容器。
+ *
+ * 优先级:组件 prop > ConfigProvider.popupContainer > 全局单例(G3UI.config) > body。
+ * 返回函数或 null 时一律回落到上一级,最终保证能拿到一个可用的容器。
+ */
+export function resolvePopupContainer(container?: PopupContainer | null): string | HTMLElement {
+ const value = typeof container === 'function' ? container() : container
+ if (value instanceof HTMLElement) return value
+ if (typeof value === 'string' && value) return value
+ const fallback = getGlobalConfig().popupContainer
+ const resolved = typeof fallback === 'function' ? fallback() : fallback
+ if (resolved instanceof HTMLElement) return resolved
+ if (typeof resolved === 'string' && resolved) return resolved
+ return 'body'
+}
+
+/**
+ * 组件内使用的容器解析:读 ConfigProvider 下发的默认值,允许组件用 getter 覆盖。
+ * Teleport 的 to 需要非空值,故返回值永远是字符串或元素。
+ */
+export function usePopupContainer(
+ override?: () => PopupContainer | undefined,
+): ComputedRef {
+ const config = useConfig()
+ return computed(() => {
+ const own = override?.()
+ if (own) return resolvePopupContainer(own)
+ return resolvePopupContainer(config?.popupContainer.value)
+ })
+}
diff --git a/code/g3soft-libs/packages/ui/src/_composables/useUnwrappedTrigger.ts b/code/g3soft-libs/packages/ui/src/_composables/useUnwrappedTrigger.ts
new file mode 100644
index 00000000..1e252fc4
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_composables/useUnwrappedTrigger.ts
@@ -0,0 +1,108 @@
+import {
+ Comment,
+ Fragment,
+ cloneVNode,
+ computed,
+ nextTick,
+ type Component,
+ type ComputedRef,
+ type Ref,
+ type Slots,
+ type VNode,
+} from 'vue'
+
+/**
+ * 零包裹触发器(unwrapped)的注入实现。
+ *
+ * Popover / Dropdown / Tooltip 需要「不给用户内容加一层 span」时使用本 composable,
+ * 把事件处理器直接注入用户插槽产出的单根 vnode,并用指令把其真实 DOM 同步到
+ * triggerEl(供定位锚点、点击外部判定、宽度测量三处复用)。
+ *
+ * 三个必须遵守的陷阱(已踩过):
+ * 1. `` 经 renderSlot 会产出多层 Fragment,
+ * 必须循环解包到真实元素 / 组件,否则 vnode.type === Symbol(Fragment) 直接放弃注入。
+ * 2. 注入后的 vnode 必须交给 render-function 组件渲染, 会丢失
+ * on* 监听与 dirs;且返回值必须是 cloneVNode 的原始结果(保留 __v_isVNode 等内部标记),
+ * 只能在其上追加 dirs,不能用对象展开另造一个对象。
+ * 3. 这个 render-function 组件的**对象引用必须恒定**(见 InjectedTrigger 定义处的注释)。
+ * 每次求值返回新对象会让 `` 判为类型变化 → 卸载重建触发器 DOM →
+ * focus/blur 与状态更新互相驱动成死循环,把主线程打满。
+ */
+export interface UseUnwrappedTriggerOptions {
+ /** 是否启用零包裹模式 */
+ enabled: () => boolean
+ /** 组件插槽 */
+ slots: Slots
+ /** 触发器元素出口 */
+ triggerEl: Ref
+ /** 当前是否展开(用于重渲染后补定位) */
+ isOpen: () => boolean
+ /** 待注入的事件处理器,键为 DOM 事件名(click / contextmenu / mouseenter …) */
+ handlers: Record void>
+ /** 重渲染后补一次定位 */
+ onAttach?: () => void
+}
+
+export interface UseUnwrappedTriggerReturn {
+ /** 注入完成的触发器 vnode,null 表示需要回退到包裹层 */
+ injectedTrigger: ComputedRef
+ /** 交给 `` 的渲染组件(**引用恒定**,见下方实现注释) */
+ InjectedTrigger: Component
+}
+
+export function useUnwrappedTrigger(
+ options: UseUnwrappedTriggerOptions,
+): UseUnwrappedTriggerReturn {
+ const triggerRefDirective = {
+ mounted: (el: HTMLElement) => {
+ options.triggerEl.value = el
+ // 受控打开 / contextmenu 已打开后重渲染的场景补一次定位
+ if (options.isOpen()) nextTick(() => options.onAttach?.())
+ },
+ updated: (el: HTMLElement) => {
+ options.triggerEl.value = el
+ },
+ unmounted: (el: HTMLElement) => {
+ if (options.triggerEl.value === el) options.triggerEl.value = null
+ },
+ }
+
+ const injectedTrigger = computed(() => {
+ if (!options.enabled()) return null
+ const nodes = options.slots.trigger?.() ?? []
+ const real = nodes.filter((node: VNode) => node.type !== Comment)
+ if (real.length !== 1) return null
+ let vnode: VNode = real[0]
+ // Fragment 单子节点解包:嵌套 slot()会产出多层 Fragment
+ while (vnode.type === Fragment) {
+ const children = Array.isArray(vnode.children) ? (vnode.children as VNode[]) : []
+ const fragReal = children.filter((node) => node.type !== Comment)
+ if (fragReal.length !== 1) return null
+ vnode = fragReal[0]
+ }
+ // 文本 / 注释等 symbol 类型 vnode 无法承载事件与指令,回退包裹层
+ if (typeof vnode.type === 'symbol') return null
+ const injected = cloneVNode(vnode, options.handlers, true)
+ // Vue 3 的 vnode 指令字段名是 dirs(不是 directives),拼错会被静默忽略。
+ // 这里构造的是渲染器直接消费的 binding 对象,不属于公开类型,故做一次断言。
+ injected.dirs = [...(vnode.dirs || []), { dir: triggerRefDirective }] as typeof injected.dirs
+ return injected
+ })
+
+ /**
+ * 渲染注入后触发器的组件。
+ *
+ * ⚠️ 这里**必须**是恒定的对象引用,不能写成 computed 里返回 `{ render: () => vnode }`:
+ * computed 每次求值都会产出新的组件对象,`` 在 patch 时按对象引用比较,
+ * 判定为「类型变了」就会卸载旧组件、挂载新组件 —— 触发器的真实 DOM(常见是 ``)
+ * 随之被销毁重建。焦点一丢就派发 focusout,处理器又改状态导致下一次重建,于是
+ * 「重建 → 失焦 → 改状态 → 重建」自己把自己转起来,主线程被打满(表现是点击后整机卡死)。
+ * 类型保持恒定、内容由 render 内部读取 computed,DOM 才会被正常 patch 复用。
+ */
+ const InjectedTrigger: Component = {
+ name: 'G3UnwrappedTrigger',
+ render: () => injectedTrigger.value,
+ }
+
+ return { injectedTrigger, InjectedTrigger }
+}
diff --git a/code/g3soft-libs/packages/ui/src/_utils/drag.ts b/code/g3soft-libs/packages/ui/src/_utils/drag.ts
new file mode 100644
index 00000000..0de973ba
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/drag.ts
@@ -0,0 +1,207 @@
+/**
+ * 弹层拖拽 / 缩放辅助(基于 Pointer Events,跨鼠标 / 触屏统一)。
+ *
+ * - bindDrag:让面板跟随手柄移动(如 Modal 标题栏),拖拽期间抑制文本选中
+ * - bindResize:拖动手柄调整面板宽高(如四周缩放手柄),带最小 / 最大尺寸约束
+ *
+ * 均为纯 DOM 辅助:接收元素参数,不依赖组件实例。
+ * 拖拽 / 缩放通过修改面板的 transform / 回调尺寸完成,不改变文档流布局。
+ */
+
+function releaseUserSelect(): void {
+ document.body.style.userSelect = ''
+ document.body.style.webkitUserSelect = ''
+}
+
+export interface DragHandle {
+ /** 复位面板到初始位置并结束进行中的拖拽 */
+ reset: () => void
+ /** 解绑全部监听,面板销毁前调用 */
+ destroy: () => void
+}
+
+export interface BindDragOptions {
+ /** 是否约束在视口内 */
+ constrain?: boolean
+ /** 面板至少保留在视口内的像素数 */
+ minVisible?: number
+}
+
+/** 移动拖拽:以 handle 拖动 panel,位移写在 panel.style.transform 上。 */
+export function bindDrag(
+ panel: HTMLElement | null,
+ handle: HTMLElement | null,
+ options: BindDragOptions = {},
+): DragHandle {
+ 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
+ let base: { left: number; top: number; width: number; height: number } | null = null
+ let dragging = false
+
+ function onPointerDown(event: PointerEvent) {
+ if (event.button !== 0) return
+ // 手柄内的交互元素(按钮 / 链接 / 表单控件)不触发拖拽
+ if ((event.target as HTMLElement).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: PointerEvent) {
+ if (!dragging || !base) 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)
+ },
+ }
+}
+
+export interface ResizeHandle {
+ /** 解绑全部监听,面板销毁前调用 */
+ destroy: () => void
+}
+
+export interface BindResizeOptions {
+ /** 拖拽方向:e 右 / w 左 / s 下 / n 上,可组合如 'se' */
+ direction?: string
+ minWidth?: number
+ minHeight?: number
+ maxWidth?: number
+ maxHeight?: number
+ /** 尺寸变化回调 */
+ onResize?: (size: { width: number; height: number }) => void
+}
+
+/** 尺寸缩放拖拽:拖动手柄按方向增减面板宽高,结果通过 onResize 交给调用方。 */
+export function bindResize(
+ panel: HTMLElement | null,
+ handle: HTMLElement | null,
+ options: BindResizeOptions = {},
+): ResizeHandle {
+ const {
+ direction = 'se',
+ minWidth = 200,
+ minHeight = 120,
+ maxWidth = typeof window === 'undefined' ? 0 : window.innerWidth,
+ maxHeight = typeof window === 'undefined' ? 0 : window.innerHeight,
+ onResize = () => {},
+ } = options
+ if (!panel || !handle) return { destroy: () => {} }
+
+ let startX = 0
+ let startY = 0
+ let base: { width: number; height: number } | null = null
+ let dragging = false
+
+ function onPointerDown(event: PointerEvent) {
+ 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: PointerEvent) {
+ if (!dragging || !base) 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/g3soft-libs/packages/ui/src/_utils/focus.ts b/code/g3soft-libs/packages/ui/src/_utils/focus.ts
new file mode 100644
index 00000000..cc96b77b
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/focus.ts
@@ -0,0 +1,50 @@
+/**
+ * 弹层焦点管理辅助函数。
+ * 用于 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(',')
+
+/** 获取容器内当前可聚焦的元素(可见且未禁用)。 */
+export function getFocusable(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ (el) => el.getClientRects().length > 0,
+ )
+}
+
+/** 聚焦容器内第一个可聚焦元素;没有时聚焦容器本身。 */
+export function focusFirst(container: HTMLElement): void {
+ const focusable = getFocusable(container)
+ if (focusable.length > 0) focusable[0].focus()
+ else container.focus()
+}
+
+/** Tab 焦点陷阱:焦点在容器首尾或容器外时循环回弹。在容器自身的 keydown 上调用。 */
+export function trapFocus(event: KeyboardEvent, container: HTMLElement): void {
+ 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/g3soft-libs/packages/ui/src/_utils/global-config.ts b/code/g3soft-libs/packages/ui/src/_utils/global-config.ts
index 9ca39217..5507712f 100644
--- a/code/g3soft-libs/packages/ui/src/_utils/global-config.ts
+++ b/code/g3soft-libs/packages/ui/src/_utils/global-config.ts
@@ -14,6 +14,8 @@ export interface GlobalConfig {
zIndex?: number
locale?: Locale
popupContainer?: PopupContainer
+ /** 按组件维度的默认 props,如 { button: { size: 'small' } }(ConfigProvider 的组件外兜底) */
+ componentDefaults?: Record>
}
let config: GlobalConfig = {}
diff --git a/code/g3soft-libs/packages/ui/src/_utils/position.test.ts b/code/g3soft-libs/packages/ui/src/_utils/position.test.ts
new file mode 100644
index 00000000..52223dc5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/position.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from 'vitest'
+import type { Placement, RectLike } from './position'
+import { PLACEMENTS, VIEWPORT_MARGIN, computePosition } from './position'
+
+/** 视口固定为 1000×800,让断言与浏览器尺寸无关 */
+const VIEWPORT = { width: 1000, height: 800 }
+
+/** 视口中部的触发器:left 100 / top 100 / 100×20 */
+function trigger(overrides: Partial = {}): RectLike {
+ return { top: 100, left: 100, right: 200, bottom: 120, width: 100, height: 20, ...overrides }
+}
+
+function compute(placement: Placement, options: {
+ rect?: RectLike
+ floatSize?: { width: number; height: number }
+ offset?: number
+} = {}) {
+ return computePosition({
+ placement,
+ triggerRect: options.rect ?? trigger(),
+ floatSize: options.floatSize ?? { width: 120, height: 60 },
+ offset: options.offset ?? 0,
+ viewport: VIEWPORT,
+ })
+}
+
+describe('常量', () => {
+ it('12 个方位、视口安全边距 8px', () => {
+ expect(PLACEMENTS).toHaveLength(12)
+ expect(new Set(PLACEMENTS).size).toBe(12)
+ expect(VIEWPORT_MARGIN).toBe(8)
+ })
+})
+
+describe('computePosition 基础摆放', () => {
+ it('bottom 系列:贴着触发器下沿,三种对齐方式', () => {
+ expect(compute('bottom-start', { offset: 8 })).toMatchObject({ x: 100, y: 128 })
+ expect(compute('bottom-end', { offset: 8 })).toMatchObject({ x: 80, y: 128 })
+ expect(compute('bottom', { offset: 8 })).toMatchObject({ x: 90, y: 128 })
+ })
+
+ it('top 系列:贴着触发器上沿', () => {
+ expect(compute('top-start', { offset: 8 })).toMatchObject({ x: 100, y: 32 })
+ expect(compute('top-end', { offset: 8 })).toMatchObject({ x: 80, y: 32 })
+ })
+
+ it('right 系列:水平贴合右侧,垂直按对齐', () => {
+ expect(compute('right-start', { offset: 8 })).toMatchObject({ x: 208, y: 100 })
+ })
+
+ it('left 系列:左边距充足时贴左侧', () => {
+ const rect = trigger({ left: 300, right: 400 })
+ expect(compute('left-start', { rect, offset: 8 })).toMatchObject({
+ x: 172,
+ y: 100,
+ placement: 'left-start',
+ })
+ })
+
+ it('左边距不足时翻转到右侧', () => {
+ const result = compute('left-start', { offset: 8 })
+ expect(result.placement).toBe('right-start')
+ expect(result.x).toBe(208)
+ })
+
+ it('未翻转时返回期望方位', () => {
+ expect(compute('bottom-start').placement).toBe('bottom-start')
+ expect(compute('top-end').placement).toBe('top-end')
+ })
+})
+
+describe('computePosition 翻转(flip)', () => {
+ it('下方放不下时翻到上方', () => {
+ const result = compute('bottom-start', {
+ rect: trigger({ top: 700, bottom: 720 }),
+ floatSize: { width: 120, height: 200 },
+ offset: 0,
+ })
+ expect(result.placement).toBe('top-start')
+ // 贴触发器上沿:rect.top 700 - 浮层高 200 - offset 0
+ expect(result.y).toBe(500)
+ })
+
+ it('上方放不下时翻到下方', () => {
+ const result = compute('top-start', {
+ rect: trigger({ top: 20, bottom: 40 }),
+ floatSize: { width: 120, height: 200 },
+ })
+ expect(result.placement).toBe('bottom-start')
+ expect(result.y).toBe(40)
+ })
+
+ it('翻转后依然放不下时保留期望方位,交由 shift 钳制', () => {
+ const result = compute('bottom-start', {
+ rect: trigger({ top: 300, bottom: 320 }),
+ floatSize: { width: 120, height: 900 },
+ })
+ expect(result.placement).toBe('bottom-start')
+ expect(result.y).toBe(8)
+ })
+
+ it('翻转保留对齐方式', () => {
+ const result = compute('bottom-end', {
+ rect: trigger({ top: 700, bottom: 720 }),
+ floatSize: { width: 120, height: 200 },
+ })
+ expect(result.placement).toBe('top-end')
+ })
+})
+
+describe('computePosition 视口钳制(shift)', () => {
+ it('右侧越界时回推到安全边距内', () => {
+ const result = compute('bottom-start', {
+ rect: trigger({ left: 950, right: 1050 }),
+ floatSize: { width: 200, height: 60 },
+ })
+ expect(result.x).toBe(VIEWPORT.width - 200 - VIEWPORT_MARGIN)
+ })
+
+ it('左侧越界时拉到安全边距', () => {
+ const result = compute('bottom-end', {
+ rect: trigger({ left: 0, right: 40 }),
+ floatSize: { width: 200, height: 60 },
+ })
+ expect(result.x).toBe(VIEWPORT_MARGIN)
+ })
+
+ it('浮层比视口还大时钳到安全边距,不出现负值', () => {
+ const result = compute('bottom-start', { floatSize: { width: 2000, height: 60 } })
+ expect(result.x).toBe(VIEWPORT_MARGIN)
+ expect(result.x).toBeGreaterThanOrEqual(0)
+ })
+
+ it('上下越界时同样钳制', () => {
+ const result = compute('bottom-start', {
+ rect: trigger({ top: 790, bottom: 799 }),
+ floatSize: { width: 120, height: 100 },
+ })
+ expect(result.y).toBeLessThanOrEqual(VIEWPORT.height - 100 - VIEWPORT_MARGIN)
+ expect(result.y).toBeGreaterThanOrEqual(VIEWPORT_MARGIN)
+ })
+})
+
+describe('computePosition 纯函数性质', () => {
+ it('不修改传入的 rect / floatSize', () => {
+ const rect = trigger()
+ const floatSize = { width: 120, height: 60 }
+ computePosition({ placement: 'bottom-start', triggerRect: rect, floatSize, viewport: VIEWPORT })
+ expect(rect).toEqual(trigger())
+ expect(floatSize).toEqual({ width: 120, height: 60 })
+ })
+
+ it('同一输入必得同一输出', () => {
+ const first = compute('bottom-start', { offset: 12 })
+ const second = compute('bottom-start', { offset: 12 })
+ expect(first).toEqual(second)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/_utils/position.ts b/code/g3soft-libs/packages/ui/src/_utils/position.ts
new file mode 100644
index 00000000..1199c403
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/position.ts
@@ -0,0 +1,173 @@
+/**
+ * 浮层定位纯函数。
+ *
+ * 输入触发器与浮层尺寸、期望方位、偏移量与视口,返回无碰撞的落点坐标。
+ * 纯函数、无副作用,便于单测;组件层只负责取 rect 与写 style。
+ */
+
+/** 浮层主方向。 */
+export type PlacementBase = 'top' | 'bottom' | 'left' | 'right'
+
+/** 对齐方式:start = 与触发器起始边对齐,end = 与结束边对齐,center = 居中。 */
+export type PlacementAlign = 'start' | 'center' | 'end'
+
+/** 12 个可选方位:4 主方向 × 3 对齐。 */
+export type Placement =
+ | 'top'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'left'
+ | 'left-start'
+ | 'left-end'
+ | 'right'
+ | 'right-start'
+ | 'right-end'
+
+export const PLACEMENTS: Placement[] = [
+ 'top',
+ 'top-start',
+ 'top-end',
+ 'bottom',
+ 'bottom-start',
+ 'bottom-end',
+ 'left',
+ 'left-start',
+ 'left-end',
+ 'right',
+ 'right-start',
+ 'right-end',
+]
+
+/** 视口安全边距(px)。 */
+export const VIEWPORT_MARGIN = 8
+
+/** 主方向相反表(用于 flip)。 */
+const OPPOSITE: Record = {
+ top: 'bottom',
+ bottom: 'top',
+ left: 'right',
+ right: 'left',
+}
+
+export interface RectLike {
+ top: number
+ left: number
+ right: number
+ bottom: number
+ width: number
+ height: number
+}
+
+export interface SizeLike {
+ width: number
+ height: number
+}
+
+export interface ComputePositionOptions {
+ /** 期望方位 */
+ placement: Placement
+ /** 触发器位置(通常来自 getBoundingClientRect) */
+ triggerRect: RectLike
+ /** 浮层尺寸 */
+ floatSize: SizeLike
+ /** 与触发器的间距 */
+ offset?: number
+ /** 视口尺寸,默认取 window 当前尺寸 */
+ viewport?: SizeLike
+}
+
+export interface ComputePositionResult {
+ x: number
+ y: number
+ /** 实际生效的方位(可能因 flip 与期望值不同) */
+ placement: Placement
+}
+
+function parsePlacement(placement: Placement): { base: PlacementBase; alignment: PlacementAlign } {
+ const [base, alignment = 'center'] = placement.split('-') as [PlacementBase, PlacementAlign]
+ return { base, alignment }
+}
+
+function align(
+ alignment: PlacementAlign,
+ start: number,
+ size: number,
+ floatSize: number,
+): number {
+ if (alignment === 'start') return start
+ if (alignment === 'end') return start + size - floatSize
+ return start + (size - floatSize) / 2
+}
+
+/** 按 base + alignment 计算初始坐标。 */
+function place(
+ base: PlacementBase,
+ alignment: PlacementAlign,
+ rect: RectLike,
+ size: SizeLike,
+ offset: number,
+): { x: number; y: number } {
+ 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 overflows(x: number, y: number, size: SizeLike, viewport: SizeLike): boolean {
+ return (
+ x < VIEWPORT_MARGIN ||
+ y < VIEWPORT_MARGIN ||
+ x + size.width > viewport.width - VIEWPORT_MARGIN ||
+ y + size.height > viewport.height - VIEWPORT_MARGIN
+ )
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value))
+}
+
+/** 读取当前视口尺寸。 */
+export function getViewport(): SizeLike {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }
+}
+
+/**
+ * 计算浮层落点:先按期望方位摆放,主轴放不下则翻转,最后 shift 回视口内。
+ * 浮层比视口还大时 shift 失去意义,此时钳制到安全边距内。
+ */
+export function computePosition(options: ComputePositionOptions): ComputePositionResult {
+ const { placement, triggerRect, floatSize, offset = 0, viewport = getViewport() } = options
+ const { base, alignment } = parsePlacement(placement)
+ const initial = place(base, alignment, triggerRect, floatSize, offset)
+ let { x, y } = initial
+
+ 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
+ }
+ }
+
+ x = clamp(x, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, viewport.width - floatSize.width - VIEWPORT_MARGIN))
+ y = clamp(y, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, viewport.height - floatSize.height - VIEWPORT_MARGIN))
+
+ const resolvedPlacement: Placement =
+ alignment === 'center' ? resolvedBase : (`${resolvedBase}-${alignment}` as Placement)
+ return { x, y, placement: resolvedPlacement }
+}
diff --git a/code/g3soft-libs/packages/ui/src/_utils/scroll.ts b/code/g3soft-libs/packages/ui/src/_utils/scroll.ts
new file mode 100644
index 00000000..32c7b867
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/scroll.ts
@@ -0,0 +1,43 @@
+/**
+ * body 滚动锁。
+ * 弹窗(Modal / Drawer)打开时锁定页面滚动,关闭时恢复。
+ * 引用计数:多个弹窗(含弹窗内的下拉)同时打开时,最后一个关闭才真正恢复滚动。
+ */
+
+let lockCount = 0
+let restoreStyle: { overflow: string; paddingRight: string } | null = null
+
+/**
+ * 锁定 body 滚动。首次锁定时记录原样式并补偿滚动条宽度,避免锁定瞬间内容横向跳动。
+ */
+export function lockScroll(): void {
+ if (typeof document === 'undefined') return
+ if (lockCount === 0) {
+ const body = document.body
+ restoreStyle = {
+ overflow: body.style.overflow,
+ paddingRight: body.style.paddingRight,
+ }
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
+ if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`
+ body.style.overflow = 'hidden'
+ }
+ lockCount++
+}
+
+/** 解锁 body 滚动。所有弹窗关闭后恢复原始样式。 */
+export function unlockScroll(): void {
+ if (typeof document === 'undefined') return
+ if (lockCount === 0) return
+ lockCount--
+ if (lockCount === 0 && restoreStyle) {
+ document.body.style.overflow = restoreStyle.overflow
+ document.body.style.paddingRight = restoreStyle.paddingRight
+ restoreStyle = null
+ }
+}
+
+/** 当前锁定层数(调试用)。 */
+export function getScrollLockCount(): number {
+ return lockCount
+}
diff --git a/code/g3soft-libs/packages/ui/src/_utils/z-index.test.ts b/code/g3soft-libs/packages/ui/src/_utils/z-index.test.ts
new file mode 100644
index 00000000..606698d2
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/_utils/z-index.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { getBaseZIndex, nextZIndex, releaseZIndex, resetZIndex, setBaseZIndex } from './z-index'
+
+/**
+ * 分层规则是弹层可见性的地基,回归成本极低但踩过坑:
+ * - 档位顺序写反 → 弹窗内的下拉被遮罩盖住(表现为「下拉是空的」)
+ * - 档内回填最小空位 → 先关中间一个再开新的,新浮层反而被旧浮层压住
+ */
+describe('z-index 分层注册表', () => {
+ beforeEach(() => {
+ setBaseZIndex(2000)
+ })
+
+ it('跨档严格递增:modal < popup < feedback', () => {
+ const modal = nextZIndex('modal')
+ const popup = nextZIndex('popup')
+ const feedback = nextZIndex('feedback')
+
+ expect([modal, popup, feedback]).toEqual([3001, 4001, 5001])
+ expect(modal).toBeLessThan(popup)
+ expect(popup).toBeLessThan(feedback)
+ })
+
+ it('档内按打开顺序严格递增', () => {
+ expect([nextZIndex('popup'), nextZIndex('popup'), nextZIndex('popup')]).toEqual([
+ 4001, 4002, 4003,
+ ])
+ })
+
+ it('释放中间值后再取,仍高于存活层级(不回填空位)', () => {
+ const first = nextZIndex('popup')
+ const released = nextZIndex('popup')
+ const third = nextZIndex('popup')
+
+ releaseZIndex(released)
+
+ expect(first).toBe(4001)
+ expect(released).toBe(4002)
+ expect(third).toBe(4003)
+ expect(nextZIndex('popup')).toBeGreaterThan(third)
+ })
+
+ it('档内全部释放后回到起点:反复开关不会把层级推高', () => {
+ const values = Array.from({ length: 8 }, () => nextZIndex('popup'))
+ values.forEach((value) => releaseZIndex(value))
+
+ expect(nextZIndex('popup')).toBe(4001)
+ })
+
+ it('setBaseZIndex 平移全部档位并清空占用', () => {
+ nextZIndex('modal')
+ // base 是 modal 档的起点基准:modal = base+1000、popup = base+2000、feedback = base+3000
+ setBaseZIndex(100)
+
+ expect(getBaseZIndex()).toBe(100)
+ expect(nextZIndex('modal')).toBe(1101)
+ expect(nextZIndex('popup')).toBe(2101)
+ expect(nextZIndex('feedback')).toBe(3101)
+ })
+
+ it('resetZIndex 清空占用;释放不存在的层级是安全的', () => {
+ nextZIndex('popup')
+ resetZIndex()
+ expect(nextZIndex('popup')).toBe(4001)
+
+ expect(() => releaseZIndex(999_999)).not.toThrow()
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/_utils/z-index.ts b/code/g3soft-libs/packages/ui/src/_utils/z-index.ts
index 26b98d16..3d710e62 100644
--- a/code/g3soft-libs/packages/ui/src/_utils/z-index.ts
+++ b/code/g3soft-libs/packages/ui/src/_utils/z-index.ts
@@ -1,31 +1,98 @@
/**
- * zIndex 全局注册表。
+ * zIndex 分层注册表。
*
- * 弹层类组件(Dropdown / Modal / Message …)谁压谁必须统一分配,
- * 禁止组件自己写死 z-index。起点来自 ConfigProvider 的 zIndex(默认 2000)。
+ * 弹层类组件(Popover / Dropdown / Select / Modal / Drawer / Message …)谁压谁必须统一分配,
+ * 禁止组件自己写死 z-index。
+ *
+ * 分三档(相对基准层级的偏移):
+ * modal ← 弹窗:Modal / Drawer
+ * popup ← 浮层:Popover / Tooltip / Dropdown / Select / DatePicker 面板
+ * feedback ← 反馈:Message / Notification
+ *
+ * 为什么浮层必须高于弹窗:两者都 Teleport 到 body,是兄弟节点,
+ * 弹窗内的下拉若层级更低就会被弹窗面板盖住(表现为「下拉是空的」)。
+ * 因此 popup 档整体位于 modal 档之上,与 fms 的 z-modal 3000 < z-popover 3500 一致。
+ *
+ * 档内取值规则(保证「后打开的一定压住先打开的」):
+ * - 档内为空:从 +1 起步(层级不会随开合次数一路增长);
+ * - 档内已有:取当前最大值 +1,因此同屏多个浮层严格按打开顺序递增。
+ * (若按「最小可用空位」填洞,先关掉中间一个再开新的,新浮层会拿到更小的值,
+ * 出现「后开的反而被先开的盖住」。)
+ * - 关闭时用 releaseZIndex 回收;全部关闭后再打开会回到起点。
+ *
+ * 跨档不保证「后开的更高」:popup 档整体高于 modal 档,所以弹窗里开的下拉
+ * 会浮在之后打开的新弹窗之上。这是分层编号的固有特性(fms 同样如此),
+ * 实践中打开新弹窗的那次点击会先让旧浮层按「点击外部」关闭,不会真的叠加。
+ *
+ * 注意:用 ConfigProvider.popupContainer 把浮层挂到局部容器时,档位只在该容器内
+ * 递增;容器自身的层级需要调用方自己保证(它会与页面其他内容是上下层关系)。
*/
+/** 层级档位。 */
+export type ZIndexLayer = 'popup' | 'modal' | 'feedback'
+
+const LAYER_OFFSET: Record = {
+ modal: 1000,
+ popup: 2000,
+ feedback: 3000,
+}
+
+const LAYERS = Object.keys(LAYER_OFFSET) as ZIndexLayer[]
+
+/** 默认基准:modal 从 3001 起、popup 从 4001 起、feedback 从 5001 起。 */
const DEFAULT_BASE_Z_INDEX = 2000
+/**
+ * 基准值 = **modal 档的起点基准**(不是"所有弹层的起点"):
+ * modal = base + 1000 + 序号,popup = base + 2000 + 序号,feedback = base + 3000 + 序号。
+ * 所以 `ConfigProvider :z-index="100"` 得到的是 modal 1101 / popup 2101 / feedback 3101。
+ */
let base = DEFAULT_BASE_Z_INDEX
-let current = base
-/** 由 ConfigProvider 挂载时设置基准层级。 */
+/** 各档位当前占用的层级值(打开时压入,关闭时回收)。 */
+const live: Record = { popup: [], modal: [], feedback: [] }
+
+/** 由 ConfigProvider 挂载 / G3UI.config 时设置基准层级。 */
export function setBaseZIndex(value: number): void {
base = value
- current = value
+ LAYERS.forEach((layer) => {
+ live[layer] = []
+ })
}
export function getBaseZIndex(): number {
return base
}
-/** 取下一个可用层级(按序分配)。 */
-export function nextZIndex(): number {
- return ++current
+/**
+ * 取本档位下一个层级:档内为空时从 +1 起步,否则取档内最大值 +1。
+ * 这样「后打开的一定高于先打开的」,同时层级不会因为反复开关而无上限增长。
+ *
+ * 不传档位时按 popup 处理,兼容早期只弹出层的调用点。
+ */
+export function nextZIndex(layer: ZIndexLayer = 'popup'): number {
+ const offset = LAYER_OFFSET[layer]
+ const list = live[layer]
+ const value = (list.length > 0 ? Math.max(...list) : base + offset) + 1
+ list.push(value)
+ return value
}
-/** 重置到基准层级(用于测试 / 整页卸载)。 */
+/** 回收某个层级(弹层关闭 / 卸载时调用),供后续实例复用。 */
+export function releaseZIndex(value: number): void {
+ for (const layer of LAYERS) {
+ const list = live[layer]
+ const index = list.indexOf(value)
+ if (index !== -1) {
+ list.splice(index, 1)
+ return
+ }
+ }
+}
+
+/** 清空全部档位占用(用于测试 / 整页卸载)。 */
export function resetZIndex(): void {
- current = base
+ LAYERS.forEach((layer) => {
+ live[layer] = []
+ })
}
diff --git a/code/g3soft-libs/packages/ui/src/button-group/ButtonGroup.vue b/code/g3soft-libs/packages/ui/src/button-group/ButtonGroup.vue
new file mode 100644
index 00000000..a0fd2666
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/button-group/ButtonGroup.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/button-group/ButtonGroupSeparator.vue b/code/g3soft-libs/packages/ui/src/button-group/ButtonGroupSeparator.vue
new file mode 100644
index 00000000..992c33ce
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/button-group/ButtonGroupSeparator.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/button-group/index.ts b/code/g3soft-libs/packages/ui/src/button-group/index.ts
new file mode 100644
index 00000000..f258a9f1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/button-group/index.ts
@@ -0,0 +1,7 @@
+export { default as G3ButtonGroup } from './ButtonGroup.vue'
+export { default as G3ButtonGroupSeparator } from './ButtonGroupSeparator.vue'
+export type {
+ ButtonGroupOrientation,
+ ButtonGroupProps,
+ ButtonGroupSeparatorProps,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/button-group/types.ts b/code/g3soft-libs/packages/ui/src/button-group/types.ts
new file mode 100644
index 00000000..6b69c6d3
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/button-group/types.ts
@@ -0,0 +1,12 @@
+/** 按钮组排列方向 */
+export type ButtonGroupOrientation = 'horizontal' | 'vertical'
+
+export interface ButtonGroupProps {
+ /** 排列方向 */
+ orientation?: ButtonGroupOrientation
+}
+
+export interface ButtonGroupSeparatorProps {
+ /** 分割线方向;应与所在按钮组的排列方向相反 */
+ orientation?: ButtonGroupOrientation
+}
diff --git a/code/g3soft-libs/packages/ui/src/button/Button.vue b/code/g3soft-libs/packages/ui/src/button/Button.vue
index 9fe67170..8622e528 100644
--- a/code/g3soft-libs/packages/ui/src/button/Button.vue
+++ b/code/g3soft-libs/packages/ui/src/button/Button.vue
@@ -1,47 +1,53 @@
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/checkbox/index.ts b/code/g3soft-libs/packages/ui/src/checkbox/index.ts
new file mode 100644
index 00000000..f8008d23
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/checkbox/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Checkbox } from './Checkbox.vue'
+export type { CheckboxProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/checkbox/types.ts b/code/g3soft-libs/packages/ui/src/checkbox/types.ts
new file mode 100644
index 00000000..d0d2c6cb
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/checkbox/types.ts
@@ -0,0 +1,12 @@
+export interface CheckboxProps {
+ /** 双向绑定的选中状态 */
+ modelValue?: boolean
+ /** 半选状态,用于表格全选 / 部分选中场景 */
+ indeterminate?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+ /** 原生 name,用于表单提交分组 */
+ name?: string
+ /** 原生 value,勾选时随表单提交的值 */
+ value?: string | number | boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/date/CalendarPanel.vue b/code/g3soft-libs/packages/ui/src/date/CalendarPanel.vue
new file mode 100644
index 00000000..97676e8a
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/CalendarPanel.vue
@@ -0,0 +1,825 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ weekday }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 开始
+
+
+
+ 结束
+
+
+
+
+
+
+
+
+ {{ locale.common.confirm }}
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/date/DatePicker.vue b/code/g3soft-libs/packages/ui/src/date/DatePicker.vue
new file mode 100644
index 00000000..69201b0d
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/DatePicker.vue
@@ -0,0 +1,350 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/date/RangePicker.vue b/code/g3soft-libs/packages/ui/src/date/RangePicker.vue
new file mode 100644
index 00000000..2c88e8e9
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/RangePicker.vue
@@ -0,0 +1,372 @@
+
+
+
+
+
+
+
+
+ ~
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/date/TimeSelect.vue b/code/g3soft-libs/packages/ui/src/date/TimeSelect.vue
new file mode 100644
index 00000000..b49b2c0b
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/TimeSelect.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+ -
+ {{ optionLabel(value) }}
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/date/date-utils.test.ts b/code/g3soft-libs/packages/ui/src/date/date-utils.test.ts
new file mode 100644
index 00000000..8598853f
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/date-utils.test.ts
@@ -0,0 +1,344 @@
+import { describe, expect, it } from 'vitest'
+import {
+ addMonth,
+ analyzeDateFormat,
+ canonicalizeDateText,
+ combineDateTime,
+ compareDate,
+ compareDay,
+ endOfDay,
+ formatDate,
+ getDaysInMonth,
+ getMonthMatrix,
+ getMonthRows,
+ isInRange,
+ isSameDay,
+ isSameMonth,
+ normalizeDateForFormat,
+ parseDate,
+ parseDateInput,
+ resolveDateSegment,
+ startOfDay,
+ stepDateSegment,
+ tokenizeDateFormat,
+} from './date-utils'
+
+/** 固定时刻:2026-10-08 09:05:03.123(本地时区,断言与机器时区无关) */
+const SAMPLE = new Date(2026, 9, 8, 9, 5, 3, 123)
+
+describe('tokenizeDateFormat', () => {
+ it('抽出标准 token', () => {
+ expect(tokenizeDateFormat('YYYY-MM-DD HH:mm:ss')).toEqual(['YYYY', 'MM', 'DD', 'HH', 'mm', 'ss'])
+ expect(tokenizeDateFormat('h:mm A')).toEqual(['h', 'mm', 'A'])
+ })
+
+ it('中文字面量被跳过,方括号内容整体忽略', () => {
+ expect(tokenizeDateFormat('YYYY年MM月DD日')).toEqual(['YYYY', 'MM', 'DD'])
+ expect(tokenizeDateFormat('YYYY-MM-DD [HH:mm]')).toEqual(['YYYY', 'MM', 'DD'])
+ })
+})
+
+describe('analyzeDateFormat(格式 → 面板能力)', () => {
+ it('纯日期:只有日期部分、无时间列', () => {
+ const config = analyzeDateFormat('YYYY-MM-DD')
+ expect(config).toMatchObject({
+ primaryFormat: 'YYYY-MM-DD',
+ hasYear: true,
+ hasMonth: true,
+ hasDay: true,
+ hasDateParts: true,
+ hasTime: false,
+ hour12: false,
+ meridiem: false,
+ })
+ expect(config.timeFields).toEqual([])
+ })
+
+ it('日期时间:时间列按 时→分→秒 顺序,秒缺失时不出现', () => {
+ expect(analyzeDateFormat('YYYY-MM-DD HH:mm:ss').timeFields).toEqual(['hour', 'minute', 'second'])
+ expect(analyzeDateFormat('YYYY-MM-DD HH:mm').timeFields).toEqual(['hour', 'minute'])
+ expect(analyzeDateFormat('YYYY-MM-DD HH').timeFields).toEqual(['hour'])
+ expect(analyzeDateFormat('YYYY-MM-DD HH:mm').hasSecond).toBe(false)
+ })
+
+ it('12 小时制与上下午标记', () => {
+ expect(analyzeDateFormat('YYYY-MM-DD hh:mm A')).toMatchObject({
+ hour12: true,
+ meridiem: true,
+ lowerMeridiem: false,
+ })
+ expect(analyzeDateFormat('YYYY-MM-DD hh:mm a')).toMatchObject({
+ hour12: true,
+ meridiem: true,
+ lowerMeridiem: true,
+ })
+ })
+
+ it('纯时间格式:没有日期部分', () => {
+ expect(analyzeDateFormat('HH:mm')).toMatchObject({
+ hasDateParts: false,
+ hasTime: true,
+ timeFields: ['hour', 'minute'],
+ })
+ })
+
+ it('精度只到月 / 年', () => {
+ expect(analyzeDateFormat('YYYY-MM')).toMatchObject({ hasMonth: true, hasDay: false })
+ expect(analyzeDateFormat('YYYY')).toMatchObject({ hasMonth: false, hasDay: false, hasYear: true })
+ })
+
+ it('数组格式取第一个;纯星期 / 字面量回退到默认日期格式', () => {
+ expect(analyzeDateFormat(['YYYY-MM-DD', 'YYYY-MM-DD HH:mm']).hasTime).toBe(false)
+ expect(analyzeDateFormat('dddd')).toMatchObject({ primaryFormat: 'YYYY-MM-DD', hasDay: true })
+ expect(analyzeDateFormat('[第]YYYY[年]')).toMatchObject({ hasYear: true })
+ })
+
+ it('空 / 非法输入回退默认', () => {
+ expect(analyzeDateFormat(undefined).primaryFormat).toBe('YYYY-MM-DD')
+ expect(analyzeDateFormat('').primaryFormat).toBe('YYYY-MM-DD')
+ })
+})
+
+describe('formatDate', () => {
+ it('补零输出,字面量原样保留', () => {
+ expect(formatDate(SAMPLE, 'YYYY-MM-DD HH:mm:ss')).toBe('2026-10-08 09:05:03')
+ expect(formatDate(SAMPLE, 'YYYY年MM月DD日')).toBe('2026年10月08日')
+ expect(formatDate(SAMPLE, '[今天是]YYYY/MM/DD')).toBe('今天是2026/10/08')
+ expect(formatDate(SAMPLE, 'YY-M-D H:m:s')).toBe('26-10-8 9:5:3')
+ })
+
+ it('可接受 Date / 时间戳 / 可解析字符串', () => {
+ expect(formatDate(SAMPLE.getTime(), 'YYYY-MM-DD')).toBe('2026-10-08')
+ })
+
+ it('空值与非法值输出空串', () => {
+ expect(formatDate(null, 'YYYY-MM-DD')).toBe('')
+ expect(formatDate(undefined, 'YYYY-MM-DD')).toBe('')
+ expect(formatDate('', 'YYYY-MM-DD')).toBe('')
+ expect(formatDate('不是日期', 'YYYY-MM-DD')).toBe('')
+ })
+})
+
+describe('parseDate(严格解析 + 容错)', () => {
+ it('严格匹配格式', () => {
+ const parsed = parseDate('2026-10-08', 'YYYY-MM-DD')
+ expect(parsed).not.toBeNull()
+ expect(formatDate(parsed, 'YYYY-MM-DD HH:mm:ss')).toBe('2026-10-08 00:00:00')
+ })
+
+ it('斜杠 / 点号 / 中文分隔符可被规范化后解析', () => {
+ expect(formatDate(parseDate('2026/10/08', 'YYYY-MM-DD'), 'YYYY-MM-DD')).toBe('2026-10-08')
+ expect(formatDate(parseDate('2026.10.08', 'YYYY-MM-DD'), 'YYYY-MM-DD')).toBe('2026-10-08')
+ expect(formatDate(parseDate('2026年10月08日', 'YYYY-MM-DD'), 'YYYY-MM-DD')).toBe('2026-10-08')
+ })
+
+ it('越界段值判为非法(月 13 / 小时 25)', () => {
+ expect(parseDate('2026-13-01', 'YYYY-MM-DD')).toBeNull()
+ expect(parseDate('2026-10-08 25:00', 'YYYY-MM-DD HH:mm')).toBeNull()
+ expect(parseDate('2026-10-08 10:70', 'YYYY-MM-DD HH:mm')).toBeNull()
+ })
+
+ it('日期溢出判为非法(2 月 30 日不顺延)', () => {
+ expect(parseDate('2026-02-30', 'YYYY-MM-DD')).toBeNull()
+ expect(parseDate('2024-02-29', 'YYYY-MM-DD')).not.toBeNull()
+ expect(parseDate('2026-02-29', 'YYYY-MM-DD')).toBeNull()
+ })
+
+ it('空值返回 null,Date / 数字原样对待', () => {
+ expect(parseDate('', 'YYYY-MM-DD')).toBeNull()
+ expect(parseDate(null, 'YYYY-MM-DD')).toBeNull()
+ expect(parseDate(SAMPLE, 'YYYY-MM-DD')?.getTime()).toBe(SAMPLE.getTime())
+ expect(parseDate(SAMPLE.getTime(), 'YYYY-MM-DD')?.getTime()).toBe(SAMPLE.getTime())
+ })
+})
+
+describe('parseDateInput(输入框手输)', () => {
+ it('空文本是「清空」而不是错误', () => {
+ expect(parseDateInput('', 'YYYY-MM-DD')).toEqual({ date: null, isEmpty: true, isValid: true })
+ expect(parseDateInput(' ', 'YYYY-MM-DD')).toEqual({ date: null, isEmpty: true, isValid: true })
+ })
+
+ it('完全不含数字判为非法', () => {
+ const result = parseDateInput('abc', 'YYYY-MM-DD')
+ expect(result.isEmpty).toBe(false)
+ expect(result.isValid).toBe(false)
+ expect(result.date).toBeNull()
+ })
+
+ it('合法文本返回解析结果', () => {
+ const result = parseDateInput('2026-10-08', 'YYYY-MM-DD')
+ expect(result.isEmpty).toBe(false)
+ expect(result.isValid).toBe(true)
+ expect(formatDate(result.date, 'YYYY-MM-DD')).toBe('2026-10-08')
+ })
+
+ it('越界文本仍未非法', () => {
+ expect(parseDateInput('2026-13-40', 'YYYY-MM-DD').isValid).toBe(false)
+ })
+})
+
+describe('canonicalizeDateText', () => {
+ it('中文 / 斜杠 / 点号统一成横线并压缩空白', () => {
+ expect(canonicalizeDateText('2026年10月08日')).toBe('2026-10-08')
+ expect(canonicalizeDateText('2026/10/08')).toBe('2026-10-08')
+ expect(canonicalizeDateText('2026.10.08')).toBe('2026-10-08')
+ expect(canonicalizeDateText(' 2026-10-08 ')).toBe('2026-10-08')
+ })
+})
+
+describe('normalizeDateForFormat(低精度字段归零)', () => {
+ it('精度只到月:日归 1 号、时间归零', () => {
+ expect(formatDate(normalizeDateForFormat(SAMPLE, 'YYYY-MM'), 'YYYY-MM-DD HH:mm:ss')).toBe(
+ '2026-10-01 00:00:00',
+ )
+ })
+
+ it('精度只到年:月 / 日 / 时间全部归零', () => {
+ expect(formatDate(normalizeDateForFormat(SAMPLE, 'YYYY'), 'YYYY-MM-DD HH:mm:ss')).toBe(
+ '2026-01-01 00:00:00',
+ )
+ })
+
+ it('精度到分:保留时分,秒归零', () => {
+ expect(formatDate(normalizeDateForFormat(SAMPLE, 'YYYY-MM-DD HH:mm'), 'HH:mm:ss')).toBe(
+ '09:05:00',
+ )
+ })
+
+ it('接受 analyzeDateFormat 的结果,空值返回 null', () => {
+ expect(formatDate(normalizeDateForFormat(SAMPLE, analyzeDateFormat('YYYY-MM')), 'YYYY-MM-DD')).toBe(
+ '2026-10-01',
+ )
+ expect(normalizeDateForFormat(null, 'YYYY')).toBeNull()
+ })
+})
+
+describe('stepDateSegment(键盘微调)', () => {
+ it('月段按月末夹取,不溢出到下个月', () => {
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 31), 'month', 1), 'YYYY-MM-DD')).toBe(
+ '2026-02-28',
+ )
+ expect(formatDate(stepDateSegment(new Date(2026, 2, 31), 'month', -1), 'YYYY-MM-DD')).toBe(
+ '2026-02-28',
+ )
+ })
+
+ it('日段在当月内夹取,不跨月', () => {
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 31), 'day', 1), 'YYYY-MM-DD')).toBe(
+ '2026-01-31',
+ )
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1), 'day', -1), 'YYYY-MM-DD')).toBe(
+ '2026-01-01',
+ )
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 15), 'day', 1), 'YYYY-MM-DD')).toBe(
+ '2026-01-16',
+ )
+ })
+
+ it('时 / 分 / 秒回绕且不进位', () => {
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1, 23, 59, 59), 'hour', 1), 'YYYY-MM-DD HH:mm:ss')).toBe(
+ '2026-01-01 00:59:59',
+ )
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1, 23, 59, 59), 'minute', 1), 'YYYY-MM-DD HH:mm:ss')).toBe(
+ '2026-01-01 23:00:59',
+ )
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1, 23, 59, 59), 'second', 1), 'YYYY-MM-DD HH:mm:ss')).toBe(
+ '2026-01-01 23:59:00',
+ )
+ })
+
+ it('年段直接加减,上下午段翻 12 小时', () => {
+ expect(formatDate(stepDateSegment(SAMPLE, 'year', 1), 'YYYY')).toBe('2027')
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1, 8), 'meridiem', 1), 'HH')).toBe('20')
+ expect(formatDate(stepDateSegment(new Date(2026, 0, 1, 20), 'meridiem', 1), 'HH')).toBe('08')
+ })
+
+ it('空值返回 null', () => {
+ expect(stepDateSegment(null, 'day', 1)).toBeNull()
+ })
+})
+
+describe('resolveDateSegment(光标定位到段)', () => {
+ it('按 token 列出字符区间', () => {
+ expect(resolveDateSegment('2026-10-08', 0, 'YYYY-MM-DD')).toMatchObject({ field: 'year' })
+ expect(resolveDateSegment('2026-10-08', 5, 'YYYY-MM-DD')).toMatchObject({ field: 'month' })
+ expect(resolveDateSegment('2026-10-08', 9, 'YYYY-MM-DD')).toMatchObject({ field: 'day' })
+ })
+
+ it('停在分隔符上归左段,越界归最后一段', () => {
+ expect(resolveDateSegment('2026-10-08', 4, 'YYYY-MM-DD')).toMatchObject({ field: 'year' })
+ expect(resolveDateSegment('2026-10-08', 99, 'YYYY-MM-DD')).toMatchObject({ field: 'day' })
+ })
+
+ it('文本与格式不匹配时返回 null', () => {
+ expect(resolveDateSegment('随便写', 0, 'YYYY-MM-DD')).toBeNull()
+ })
+})
+
+describe('日粒度工具', () => {
+ it('startOfDay / endOfDay', () => {
+ expect(formatDate(startOfDay(SAMPLE), 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2026-10-08 00:00:00.000')
+ expect(formatDate(endOfDay(SAMPLE), 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2026-10-08 23:59:59.999')
+ expect(startOfDay(null)).toBeNull()
+ })
+
+ it('isSameDay / isSameMonth 忽略时间与日', () => {
+ expect(isSameDay(SAMPLE, new Date(2026, 9, 8, 23, 0))).toBe(true)
+ expect(isSameDay(SAMPLE, new Date(2026, 9, 9))).toBe(false)
+ expect(isSameMonth(SAMPLE, new Date(2026, 9, 31))).toBe(true)
+ expect(isSameMonth(SAMPLE, new Date(2026, 8, 30))).toBe(false)
+ expect(isSameDay(null, SAMPLE)).toBe(false)
+ })
+
+ it('compareDay 按天比、compareDate 按时刻比', () => {
+ expect(compareDay(SAMPLE, new Date(2026, 9, 8, 23, 59))).toBe(0)
+ expect(compareDay(SAMPLE, new Date(2026, 9, 9))).toBe(-1)
+ expect(compareDay(new Date(2026, 9, 9), SAMPLE)).toBe(1)
+ expect(compareDate(SAMPLE, new Date(2026, 9, 8, 9, 5, 4))).toBe(-1)
+ expect(compareDay(null, SAMPLE)).toBe(0)
+ })
+
+ it('addMonth 按月末夹取', () => {
+ expect(formatDate(addMonth(new Date(2026, 0, 31), 1), 'YYYY-MM-DD')).toBe('2026-02-28')
+ expect(formatDate(addMonth(new Date(2026, 9, 8), -1), 'YYYY-MM-DD')).toBe('2026-09-08')
+ expect(formatDate(addMonth(new Date(2026, 9, 8), 12), 'YYYY-MM-DD')).toBe('2027-10-08')
+ })
+
+ it('isInRange 闭区间且入参顺序可颠倒', () => {
+ const start = new Date(2026, 9, 1)
+ const end = new Date(2026, 9, 10)
+ expect(isInRange(new Date(2026, 9, 1), start, end)).toBe(true)
+ expect(isInRange(new Date(2026, 9, 10, 23, 59), start, end)).toBe(true)
+ expect(isInRange(new Date(2026, 9, 11), start, end)).toBe(false)
+ expect(isInRange(new Date(2026, 9, 5), end, start)).toBe(true)
+ expect(isInRange(null, start, end)).toBe(false)
+ })
+
+ it('combineDateTime 取日期的日子 + 时间的时刻,毫秒归零', () => {
+ const combined = combineDateTime(new Date(2026, 9, 8), new Date(2000, 0, 1, 13, 20, 30, 999))
+ expect(formatDate(combined, 'YYYY-MM-DD HH:mm:ss.SSS')).toBe('2026-10-08 13:20:30.000')
+ expect(formatDate(combineDateTime(new Date(2026, 9, 8)), 'HH:mm:ss')).toBe('00:00:00')
+ })
+
+ it('getDaysInMonth 处理闰年', () => {
+ expect(getDaysInMonth(2026, 1)).toBe(28)
+ expect(getDaysInMonth(2024, 1)).toBe(29)
+ expect(getDaysInMonth(2026, 0)).toBe(31)
+ expect(getDaysInMonth(2026, 3)).toBe(30)
+ })
+
+ it('getMonthMatrix 是 42 格,首格为当月 1 号所在周的周日', () => {
+ // 2026-10-01 是周四 → 首格应是 2026-09-27(周日)
+ const cells = getMonthMatrix(2026, 9)
+ expect(cells).toHaveLength(42)
+ expect(formatDate(cells[0], 'YYYY-MM-DD')).toBe('2026-09-27')
+ expect(cells[0].getDay()).toBe(0)
+ })
+
+ it('getMonthRows 切成 6 行 × 7 列', () => {
+ const rows = getMonthRows(2026, 9)
+ expect(rows).toHaveLength(6)
+ rows.forEach((row) => expect(row).toHaveLength(7))
+ expect(formatDate(rows[0][0], 'YYYY-MM-DD')).toBe('2026-09-27')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/date/date-utils.ts b/code/g3soft-libs/packages/ui/src/date/date-utils.ts
new file mode 100644
index 00000000..08f54a90
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/date-utils.ts
@@ -0,0 +1,764 @@
+/**
+ * 日期工具(零依赖实现,替代 dayjs)。
+ *
+ * 约定:内部一律用 Date 运算,对外由组件决定字符串格式;
+ * 所有函数对非法输入都返回 null / false,不抛异常。
+ */
+
+export const DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm:ss'
+export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'
+
+/** 英文月份 / 星期名(与 dayjs 默认一致,仅用于 MMM / MMMM / dddd 这类文案 token) */
+const MONTH_NAMES = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+]
+const MONTH_NAMES_SHORT = MONTH_NAMES.map((name) => name.slice(0, 3))
+const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
+const WEEKDAY_NAMES_SHORT = WEEKDAY_NAMES.map((name) => name.slice(0, 3))
+
+const pad = (value: number, length = 2) => String(Math.abs(value)).padStart(length, '0')
+
+/* ---------------- 格式规范化 ---------------- */
+
+export type DateFormatInput = string | string[] | undefined
+
+/** 归一成字符串数组;数组里非字符串 / 空串会被过滤,全空时回退 fallback */
+export function normalizeFormats(format: DateFormatInput, fallback = DEFAULT_DATE_FORMAT): string[] {
+ if (Array.isArray(format)) {
+ const list = format.filter((item): item is string => typeof item === 'string' && item !== '')
+ return list.length ? list : [fallback]
+ }
+ if (typeof format === 'string' && format !== '') return [format]
+ return [fallback]
+}
+
+export function hasFormat(format: DateFormatInput): boolean {
+ return normalizeFormats(format).length > 0
+}
+
+export function getPrimaryFormat(format: DateFormatInput, fallback = DEFAULT_DATE_FORMAT): string {
+ return normalizeFormats(format, fallback)[0]
+}
+
+/** 支持的 token,按长度优先匹配 */
+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',
+]
+
+/** 拆出格式中的 token 序列(不含字面量),`[..]` 内视为普通文本 */
+export function tokenizeDateFormat(format: string): string[] {
+ const tokens: string[] = []
+ let index = 0
+ while (index < format.length) {
+ const char = format[index]
+ if (char === '[') {
+ const end = format.indexOf(']', index)
+ index = end === -1 ? format.length : end + 1
+ continue
+ }
+ const matched = FORMAT_TOKENS.find((token) => format.startsWith(token, index))
+ if (matched) {
+ tokens.push(matched)
+ index += matched.length
+ continue
+ }
+ index += 1
+ }
+ return tokens
+}
+
+export interface DateFormatConfig {
+ primaryFormat: string
+ tokens: string[]
+ hasYear: boolean
+ hasMonth: boolean
+ hasDay: boolean
+ hasDateParts: boolean
+ hasHour: boolean
+ hasMinute: boolean
+ hasSecond: boolean
+ hasMillisecond: boolean
+ hasTime: boolean
+ timeFields: Array<'hour' | 'minute' | 'second'>
+ hour12: boolean
+ meridiem: boolean
+ lowerMeridiem: boolean
+}
+
+/** 分析格式:决定面板精度、时间列、12 小时制等 */
+export function analyzeDateFormat(format: DateFormatInput): DateFormatConfig {
+ const primaryFormat = getPrimaryFormat(format)
+ const tokens = tokenizeDateFormat(primaryFormat)
+ const hasYear = tokens.includes('YYYY') || tokens.includes('YY')
+ const hasMonth = tokens.includes('MM') || tokens.includes('M') || tokens.includes('MMM') || tokens.includes('MMMM')
+ const hasDay = tokens.includes('DD') || tokens.includes('D') || tokens.includes('Do')
+ const hasDateParts = hasYear || hasMonth || hasDay
+ const hasHour = tokens.includes('HH') || tokens.includes('H') || tokens.includes('hh') || tokens.includes('h')
+ const hasMinute = tokens.includes('mm') || tokens.includes('m')
+ const hasSecond = tokens.includes('ss') || tokens.includes('s')
+ const hasMillisecond = tokens.includes('SSS')
+ const hasTime = hasHour || hasMinute || hasSecond || hasMillisecond
+ const hour12 = tokens.includes('hh') || tokens.includes('h')
+ const meridiem = tokens.includes('A') || tokens.includes('a')
+ const lowerMeridiem = tokens.includes('a')
+
+ // 年 / 月 / 日与时间字段全无(纯星期、时区、字面量)时回退到默认格式
+ if (!hasDateParts && !hasTime) return analyzeDateFormat(DEFAULT_DATE_FORMAT)
+
+ const timeFields: Array<'hour' | 'minute' | 'second'> = []
+ if (hasHour) timeFields.push('hour')
+ if (hasMinute) timeFields.push('minute')
+ if (hasSecond) timeFields.push('second')
+
+ return {
+ primaryFormat,
+ tokens,
+ hasYear,
+ hasMonth,
+ hasDay,
+ hasDateParts,
+ hasHour,
+ hasMinute,
+ hasSecond,
+ hasMillisecond,
+ hasTime,
+ timeFields,
+ hour12,
+ meridiem,
+ lowerMeridiem,
+ }
+}
+
+/* ---------------- 格式化 ---------------- */
+
+const toDate = (value: Date | number | string | null | undefined): Date | null => {
+ if (value == null || value === '') return null
+ const date = value instanceof Date ? value : new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+/** 按格式输出字符串;两个参数顺序与 formatDate(date, format) 一致 */
+export function formatDate(value: Date | number | string | null | undefined, format: DateFormatInput = DEFAULT_FORMAT): string {
+ const date = toDate(value)
+ if (!date) return ''
+ const pattern = getPrimaryFormat(format)
+ let output = ''
+ let index = 0
+ while (index < pattern.length) {
+ const char = pattern[index]
+ if (char === '[') {
+ const end = pattern.indexOf(']', index)
+ if (end === -1) break
+ output += pattern.slice(index + 1, end)
+ index = end + 1
+ continue
+ }
+ const token = FORMAT_TOKENS.find((item) => pattern.startsWith(item, index))
+ if (!token) {
+ output += char
+ index += 1
+ continue
+ }
+ index += token.length
+ const hours24 = date.getHours()
+ switch (token) {
+ case 'YYYY':
+ output += String(date.getFullYear())
+ break
+ case 'YY':
+ output += pad(date.getFullYear() % 100)
+ break
+ case 'MMMM':
+ output += MONTH_NAMES[date.getMonth()]
+ break
+ case 'MMM':
+ output += MONTH_NAMES_SHORT[date.getMonth()]
+ break
+ case 'MM':
+ output += pad(date.getMonth() + 1)
+ break
+ case 'M':
+ output += String(date.getMonth() + 1)
+ break
+ case 'DD':
+ output += pad(date.getDate())
+ break
+ case 'D':
+ output += String(date.getDate())
+ break
+ case 'Do':
+ output += `${date.getDate()}${ordinalSuffix(date.getDate())}`
+ break
+ case 'dddd':
+ output += WEEKDAY_NAMES[date.getDay()]
+ break
+ case 'ddd':
+ output += WEEKDAY_NAMES_SHORT[date.getDay()]
+ break
+ case 'dd':
+ output += pad(date.getDay())
+ break
+ case 'd':
+ output += String(date.getDay())
+ break
+ case 'HH':
+ output += pad(hours24)
+ break
+ case 'H':
+ output += String(hours24)
+ break
+ case 'hh':
+ output += pad(hours24 % 12 || 12)
+ break
+ case 'h':
+ output += String(hours24 % 12 || 12)
+ break
+ case 'mm':
+ output += pad(date.getMinutes())
+ break
+ case 'm':
+ output += String(date.getMinutes())
+ break
+ case 'ss':
+ output += pad(date.getSeconds())
+ break
+ case 's':
+ output += String(date.getSeconds())
+ break
+ case 'SSS':
+ output += pad(date.getMilliseconds(), 3)
+ break
+ case 'A':
+ output += hours24 < 12 ? 'AM' : 'PM'
+ break
+ case 'a':
+ output += hours24 < 12 ? 'am' : 'pm'
+ break
+ case 'Z':
+ case 'ZZ': {
+ const offset = -date.getTimezoneOffset()
+ const sign = offset >= 0 ? '+' : '-'
+ const text = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
+ output += token === 'Z' ? text : text.replace(':', '')
+ break
+ }
+ }
+ }
+ return output
+}
+
+function ordinalSuffix(day: number): string {
+ if (day % 100 >= 11 && day % 100 <= 13) return 'th'
+ if (day % 10 === 1) return 'st'
+ if (day % 10 === 2) return 'nd'
+ if (day % 10 === 3) return 'rd'
+ return 'th'
+}
+
+/* ---------------- 解析 ---------------- */
+
+const SEGMENT_PATTERNS: Record = {
+ YYYY: '\\d{4}',
+ YY: '\\d{1,2}',
+ MMMM: '[A-Za-z]+',
+ MMM: '[A-Za-z]+',
+ MM: '\\d{1,2}',
+ M: '\\d{1,2}',
+ DD: '\\d{1,2}',
+ D: '\\d{1,2}',
+ Do: '\\d{1,2}(?:st|nd|rd|th)?',
+ HH: '\\d{1,2}',
+ H: '\\d{1,2}',
+ hh: '\\d{1,2}',
+ h: '\\d{1,2}',
+ mm: '\\d{1,2}',
+ m: '\\d{1,2}',
+ ss: '\\d{1,2}',
+ s: '\\d{1,2}',
+ SSS: '\\d{1,3}',
+ A: '[AaPp][Mm]',
+ a: '[AaPp][Mm]',
+}
+
+const FIELD_BY_TOKEN: Record = {
+ YYYY: 'year',
+ YY: 'year',
+ MM: 'month',
+ M: 'month',
+ MMM: 'month',
+ MMMM: 'month',
+ DD: 'day',
+ D: 'day',
+ Do: 'day',
+ HH: 'hour',
+ H: 'hour',
+ hh: 'hour',
+ h: 'hour',
+ mm: 'minute',
+ m: 'minute',
+ ss: 'second',
+ s: 'second',
+ SSS: 'millisecond',
+ A: 'meridiem',
+ a: 'meridiem',
+}
+
+interface CompiledFormat {
+ regex: RegExp
+ tokens: string[]
+ /** 每个捕获组对应的 token(不含不产生段的 token) */
+ tokenByGroup: string[]
+}
+
+const formatCache = new Map()
+
+/** 把格式编译成解析用正则;不产生段的 token(dddd/dd/d 等)只做跳读 */
+function compileFormat(format: string): CompiledFormat {
+ const cached = formatCache.get(format)
+ if (cached) return cached
+ const tokens = tokenizeDateFormat(format)
+ let pattern = '^'
+ const segmentTokens: string[] = []
+ let index = 0
+ while (index < format.length) {
+ const char = format[index]
+ if (char === '[') {
+ const end = format.indexOf(']', index)
+ const literal = end === -1 ? format.slice(index + 1) : format.slice(index + 1, end)
+ pattern += literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ index = end === -1 ? format.length : end + 1
+ continue
+ }
+ const token = tokens.find((item) => format.startsWith(item, index))
+ if (!token) {
+ pattern += '[^0-9A-Za-z]*'
+ index += 1
+ continue
+ }
+ index += token.length
+ const segment = SEGMENT_PATTERNS[token]
+ if (segment) {
+ pattern += `(${segment})`
+ segmentTokens.push(token)
+ } else {
+ pattern += '[^0-9A-Za-z]*'
+ }
+ }
+ // 末尾字面量可选(允许省略「日」这类后缀)
+ pattern += '$'
+ const compiled: CompiledFormat = {
+ regex: new RegExp(pattern),
+ tokens: segmentTokens,
+ tokenByGroup: segmentTokens,
+ }
+ formatCache.set(format, compiled)
+ return compiled
+}
+
+/**
+ * 形状像日期的文本(年-月-日 / 年-月)。
+ *
+ * 用于阻止末位的原生解析把越界日期「救活」:`new Date('2026-02-30')` 会静默顺延成 3 月 2 日,
+ * 绕过 matchFormat 里的值域与溢出校验,这类文本必须直接判非法。
+ */
+const DATE_LIKE_TEXT = /^\s*\d{1,4}\s*[-/.年]\s*\d{1,2}\s*(?:[-/.月]\s*\d{1,2})?/
+
+/** 宽松解析:逐格式严格匹配,失败后按规范化文本再试,最后退到原生解析 */
+export function parseDate(
+ value: string | number | Date | null | undefined,
+ format: DateFormatInput = DEFAULT_FORMAT,
+): Date | null {
+ if (value == null || value === '') return null
+ if (value instanceof Date || typeof value === 'number') return toDate(value)
+ const text = String(value).trim()
+ if (!text) return null
+
+ for (const candidate of normalizeFormats(format)) {
+ const strict = matchFormat(text, candidate)
+ if (strict) return strict
+ }
+ for (const candidate of normalizeFormats(format)) {
+ const normalized = canonicalizeDateText(text)
+ const loose = matchFormat(normalized, candidate)
+ if (loose) return loose
+ }
+ // 类日期文本判非法(见 DATE_LIKE_TEXT 注释);其余交给原生解析兜底(ISO / RFC 等)
+ if (DATE_LIKE_TEXT.test(text)) return null
+ return toDate(text)
+}
+
+/** 用编译后的正则抽取各字段并组装成 Date */
+function matchFormat(text: string, format: string): Date | null {
+ const compiled = compileFormat(format)
+ const matched = compiled.regex.exec(text)
+ if (!matched) return null
+ const now = new Date()
+ const parts: Record = {}
+ compiled.tokenByGroup.forEach((token, groupIndex) => {
+ const raw = matched[groupIndex + 1]
+ if (raw == null) return
+ const field = FIELD_BY_TOKEN[token]
+ if (!field) return
+ if (field === 'month' && (token === 'MMM' || token === 'MMMM')) {
+ const lower = raw.toLowerCase()
+ const monthIndex = MONTH_NAMES.findIndex(
+ (name) => name.toLowerCase() === lower || name.slice(0, 3).toLowerCase() === lower,
+ )
+ parts.month = monthIndex === -1 ? undefined : monthIndex + 1
+ return
+ }
+ if (field === 'meridiem') {
+ parts.meridiem = raw.toLowerCase().startsWith('p') ? 1 : 0
+ return
+ }
+ parts[field] = Number.parseInt(raw, 10)
+ })
+
+ // 值域检查:超出范围直接判为非法,避免静默顺延
+ if (parts.month !== undefined && (parts.month < 1 || parts.month > 12)) return null
+ if (parts.day !== undefined && (parts.day < 1 || parts.day > 31)) return null
+ if (parts.hour !== undefined && (parts.hour < 0 || parts.hour > 23)) return null
+ if (parts.minute !== undefined && (parts.minute < 0 || parts.minute > 59)) return null
+ if (parts.second !== undefined && (parts.second < 0 || parts.second > 59)) return null
+
+ let year = parts.year ?? now.getFullYear()
+ if (parts.year !== undefined && parts.year < 100) year += 2000
+ const month = (parts.month ?? 1) - 1
+ const day = parts.day ?? 1
+ let hour = parts.hour ?? 0
+ if (parts.meridiem !== undefined) hour = (hour % 12) + parts.meridiem * 12
+ const date = new Date(
+ year,
+ month,
+ day,
+ hour,
+ parts.minute ?? 0,
+ parts.second ?? 0,
+ parts.millisecond ?? 0,
+ )
+ if (Number.isNaN(date.getTime())) return null
+ // 溢出校验(如 2 月 30 日会被顺延):日期字段与实际不符即非法
+ if (parts.day !== undefined && date.getDate() !== day) return null
+ if (parts.month !== undefined && date.getMonth() !== month) return null
+ return date
+}
+
+/** 文本规范化:中文 / 斜杠 / 点号统一成标准分隔符 */
+export function canonicalizeDateText(text: string): string {
+ return text
+ .trim()
+ .replace(/年|月/g, '-')
+ .replace(/日/g, ' ')
+ .replace(/[./]/g, '-')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export interface DateInputParseResult {
+ date: Date | null
+ isEmpty: boolean
+ isValid: boolean
+}
+
+/** 手输文本解析:空串视为「清空」而非错误 */
+export function parseDateInput(
+ text: string,
+ format: DateFormatInput,
+): DateInputParseResult {
+ const trimmed = (text ?? '').trim()
+ if (!trimmed) return { date: null, isEmpty: true, isValid: true }
+ if (!/\d/.test(trimmed)) return { date: null, isEmpty: false, isValid: false }
+ const parsed =
+ parseDate(trimmed, format) ?? parseDate(canonicalizeDateText(trimmed), format) ?? null
+ return { date: parsed, isEmpty: false, isValid: parsed !== null }
+}
+
+/* ---------------- 可编辑段定位(输入框键盘微调) ---------------- */
+
+export interface DateSegment {
+ field: string
+ /** 段起始索引(含) */
+ start: number
+ /** 段结束索引(不含) */
+ end: number
+}
+
+/** 列出文本中可编辑 token 的字符区间 */
+export function listDateSegments(text: string, format: string): DateSegment[] {
+ const compiled = compileFormat(format)
+ const matched = compiled.regex.exec(text)
+ if (!matched) return []
+ const segments: DateSegment[] = []
+ let cursor = 0
+ compiled.tokenByGroup.forEach((token, groupIndex) => {
+ const raw = matched[groupIndex + 1]
+ if (raw == null) return
+ const start = matched[0].indexOf(raw, cursor)
+ if (start === -1) return
+ const field = FIELD_BY_TOKEN[token]
+ if (field) segments.push({ field, start, end: start + raw.length })
+ cursor = start + raw.length
+ })
+ return segments
+}
+
+/** 定位光标所在的段(停在分隔符上归左段,末尾归最后一段) */
+export function resolveDateSegment(
+ text: string,
+ caret: number,
+ format: string,
+): DateSegment | null {
+ const segments = listDateSegments(text, format)
+ if (!segments.length) return null
+ const position = Math.min(Math.max(caret, 0), text.length)
+ const found = segments.find((segment) => position >= segment.start && position <= segment.end)
+ return found ?? segments[segments.length - 1]
+}
+
+const WRAP_SIZES: Record = { hour: 24, minute: 60, second: 60 }
+
+/** 微调某一段:年/月/日走设值器(月末夹取),时/分/秒回绕不进位,上下午翻 12 小时 */
+export function stepDateSegment(
+ value: Date | null | undefined,
+ field: string,
+ delta = 1,
+): Date | null {
+ const date = toDate(value)
+ if (!date) return null
+ const next = new Date(date.getTime())
+ switch (field) {
+ case 'year':
+ next.setFullYear(date.getFullYear() + delta)
+ return next
+ case 'month': {
+ const month = date.getMonth() + delta
+ next.setDate(1)
+ next.setMonth(month)
+ const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()
+ next.setDate(Math.min(date.getDate(), lastDay))
+ return next
+ }
+ case 'day': {
+ next.setDate(1)
+ next.setMonth(date.getMonth())
+ const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
+ // 夹在当月 [1, lastDay] 内:否则 1 号再往前会跨到上个月末(setDate(0))
+ next.setDate(Math.min(Math.max(date.getDate() + delta, 1), lastDay))
+ return next
+ }
+ case 'hour':
+ next.setHours((date.getHours() + delta + 24) % 24)
+ return next
+ case 'minute':
+ next.setMinutes((date.getMinutes() + delta + 60) % 60)
+ return next
+ case 'second':
+ next.setSeconds((date.getSeconds() + delta + 60) % 60)
+ return next
+ case 'meridiem':
+ next.setHours((date.getHours() + delta * 12 + 24) % 24)
+ return next
+ default:
+ return next
+ }
+}
+
+export { WRAP_SIZES }
+
+/* ---------------- 值归一 ---------------- */
+
+/** 归一成 Date:字符串按给定格式解析 */
+export function normalizeDateValue(
+ value: Date | string | number | null | undefined,
+ format: DateFormatInput = DEFAULT_FORMAT,
+): Date | null {
+ if (value == null || value === '') return null
+ if (typeof value === 'string') return parseDate(value, format)
+ return toDate(value)
+}
+
+/**
+ * 按格式把「不可选的」低精度字段归零(无月 → 1 月、无日 → 1 号、无时分秒 → 0),
+ * 保证绑定值补位是确定的。
+ */
+export function normalizeDateForFormat(
+ value: Date | null | undefined,
+ format: DateFormatInput | DateFormatConfig,
+): Date | null {
+ const date = toDate(value)
+ if (!date) return null
+ const config = typeof format === 'object' && 'tokens' in format ? format : analyzeDateFormat(format)
+ const next = new Date(date.getTime())
+ if (!config.hasMonth) next.setMonth(0)
+ if (!config.hasDay) next.setDate(1)
+ if (!config.hasHour) next.setHours(0)
+ if (!config.hasMinute) next.setMinutes(0)
+ if (!config.hasSecond) next.setSeconds(0)
+ if (!config.hasMillisecond) next.setMilliseconds(0)
+ return next
+}
+
+/* ---------------- 日粒度工具 ---------------- */
+
+export function startOfDay(value: Date | null | undefined): Date | null {
+ const date = toDate(value)
+ if (!date) return null
+ const next = new Date(date.getTime())
+ next.setHours(0, 0, 0, 0)
+ return next
+}
+
+export function endOfDay(value: Date | null | undefined): Date | null {
+ const date = toDate(value)
+ if (!date) return null
+ const next = new Date(date.getTime())
+ next.setHours(23, 59, 59, 999)
+ return next
+}
+
+export function isSameDay(a: Date | null | undefined, b: Date | null | undefined): boolean {
+ const left = toDate(a)
+ const right = toDate(b)
+ if (!left || !right) return false
+ return (
+ left.getFullYear() === right.getFullYear() &&
+ left.getMonth() === right.getMonth() &&
+ left.getDate() === right.getDate()
+ )
+}
+
+export function isSameMonth(a: Date | null | undefined, b: Date | null | undefined): boolean {
+ const left = toDate(a)
+ const right = toDate(b)
+ if (!left || !right) return false
+ return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth()
+}
+
+export function isToday(value: Date | null | undefined): boolean {
+ return isSameDay(value, new Date())
+}
+
+/** 加 / 减 n 月,非法输入以当前时刻兜底 */
+export function addMonth(value: Date | null | undefined, n: number): Date {
+ const date = toDate(value) ?? new Date()
+ const next = new Date(date.getTime())
+ const targetDay = date.getDate()
+ next.setDate(1)
+ next.setMonth(date.getMonth() + n)
+ const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate()
+ next.setDate(Math.min(targetDay, lastDay))
+ return next
+}
+
+/** 按天比较:-1 / 0 / 1;任一非法返回 0 */
+export function compareDay(a: Date | null | undefined, b: Date | null | undefined): number {
+ const left = startOfDay(a)
+ const right = startOfDay(b)
+ if (!left || !right) return 0
+ const diff = left.getTime() - right.getTime()
+ return diff === 0 ? 0 : diff < 0 ? -1 : 1
+}
+
+/** 按完整时间点比较:-1 / 0 / 1;任一非法返回 0 */
+export function compareDate(a: Date | null | undefined, b: Date | null | undefined): number {
+ const left = toDate(a)
+ const right = toDate(b)
+ if (!left || !right) return 0
+ const diff = left.getTime() - right.getTime()
+ return diff === 0 ? 0 : diff < 0 ? -1 : 1
+}
+
+/** 闭区间按天判断,入参顺序可颠倒 */
+export function isInRange(
+ date: Date | null | undefined,
+ start: Date | null | undefined,
+ end: Date | null | undefined,
+): boolean {
+ const target = startOfDay(date)
+ const left = startOfDay(start)
+ const right = startOfDay(end)
+ if (!target || !left || !right) return false
+ const lo = Math.min(left.getTime(), right.getTime())
+ const hi = Math.max(left.getTime(), right.getTime())
+ return target.getTime() >= lo && target.getTime() <= hi
+}
+
+/** 日期部分取 date,时间部分取 time;秒与毫秒按 time 对齐(毫秒固定 0) */
+export function combineDateTime(
+ date: Date | null | undefined,
+ time?: Date | null | undefined,
+): Date {
+ const base = toDate(date) ?? new Date()
+ const clock = toDate(time)
+ const next = new Date(base.getTime())
+ next.setHours(
+ clock ? clock.getHours() : 0,
+ clock ? clock.getMinutes() : 0,
+ clock ? clock.getSeconds() : 0,
+ 0,
+ )
+ return next
+}
+
+/** 42 格(6×7)月历矩阵,索引 0 为当月 1 号所在周的周日 */
+export function getMonthMatrix(viewYear: number, viewMonth: number): Date[] {
+ const first = new Date(viewYear, viewMonth, 1)
+ const start = new Date(first.getTime())
+ start.setDate(1 - first.getDay())
+ const cells: Date[] = []
+ for (let index = 0; index < 42; index++) {
+ const cell = new Date(start.getTime())
+ cell.setDate(start.getDate() + index)
+ cells.push(cell)
+ }
+ return cells
+}
+
+/** 42 格按周切 6 行 */
+export function getMonthRows(viewYear: number, viewMonth: number): Date[][] {
+ const cells = getMonthMatrix(viewYear, viewMonth)
+ const rows: Date[][] = []
+ for (let index = 0; index < 6; index++) rows.push(cells.slice(index * 7, index * 7 + 7))
+ return rows
+}
+
+/** 该月的天数 */
+export function getDaysInMonth(year: number, month: number): number {
+ return new Date(year, month + 1, 0).getDate()
+}
diff --git a/code/g3soft-libs/packages/ui/src/date/index.ts b/code/g3soft-libs/packages/ui/src/date/index.ts
new file mode 100644
index 00000000..ebb36f70
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/index.ts
@@ -0,0 +1,46 @@
+export { default as G3DatePicker } from './DatePicker.vue'
+export { default as G3RangePicker } from './RangePicker.vue'
+export { default as G3TimeSelect } from './TimeSelect.vue'
+export { default as G3CalendarPanel } from './CalendarPanel.vue'
+export {
+ addMonth,
+ analyzeDateFormat,
+ canonicalizeDateText,
+ combineDateTime,
+ compareDate,
+ compareDay,
+ DEFAULT_DATE_FORMAT,
+ DEFAULT_FORMAT,
+ endOfDay,
+ formatDate,
+ getDaysInMonth,
+ getMonthMatrix,
+ getMonthRows,
+ getPrimaryFormat,
+ hasFormat,
+ isInRange,
+ isSameDay,
+ isSameMonth,
+ isToday,
+ listDateSegments,
+ normalizeDateForFormat,
+ normalizeDateValue,
+ normalizeFormats,
+ parseDate,
+ parseDateInput,
+ resolveDateSegment,
+ startOfDay,
+ stepDateSegment,
+ tokenizeDateFormat,
+} from './date-utils'
+export type { DateFormatConfig, DateFormatInput, DateInputParseResult, DateSegment } from './date-utils'
+export type {
+ CalendarMode,
+ CalendarPanelProps,
+ DateTimeDefault,
+ DatePickerProps,
+ DateValue,
+ RangePickerProps,
+ TimeField,
+ TimeSelectProps,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/date/types.ts b/code/g3soft-libs/packages/ui/src/date/types.ts
new file mode 100644
index 00000000..5d60a4a8
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/date/types.ts
@@ -0,0 +1,77 @@
+import type { DateFormatConfig } from './date-utils'
+
+/** 无值时的默认时间:start 当天起 / end 当天末 / 空为当前时刻 */
+export type DateTimeDefault = '' | 'start' | 'end'
+
+/** 时间列字段 */
+export type TimeField = 'hour' | 'minute' | 'second'
+
+/** 日期值:字符串(按 valueFormat 解析)或 Date */
+export type DateValue = string | Date | null | undefined
+
+export interface DatePickerProps {
+ /** 绑定值;字符串按 valueFormat(未设置时跟随 format)严格解析 */
+ modelValue?: DateValue
+ /** 同时决定面板精度与展示格式 */
+ format?: string | string[]
+ /** 绑定值格式;未设置时跟随 format 的首个格式 */
+ valueFormat?: string | string[]
+ /** 显示清除按钮 */
+ clearable?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+ /** 禁用日期判定 */
+ disabledDate?: (date: Date) => boolean
+ /** 无值时的默认时间 */
+ timeDefault?: DateTimeDefault
+}
+
+export interface RangePickerProps {
+ /** 绑定值 [start, end],元素为字符串(可为空串) */
+ modelValue?: Array | null
+ /** 展示格式 */
+ format?: string | string[]
+ /** 绑定值格式;未设置时跟随 format */
+ valueFormat?: string | string[]
+ /** 显示清除按钮 */
+ clearable?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+ /** 禁用日期判定 */
+ disabledDate?: (date: Date) => boolean
+}
+
+export interface TimeSelectProps {
+ /** 当前时间;为空时以当前时刻为基准 */
+ modelValue?: Date | null
+ /** 显示哪几列 */
+ fields?: TimeField[]
+ /** 12 小时制(小时列 1..12,并追加上下午列) */
+ hour12?: boolean
+ /** 上下午显示小写 am / pm */
+ lowerMeridiem?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+}
+
+export interface CalendarPanelProps {
+ /** 面板类型:单选 / 范围 */
+ type?: 'date' | 'range'
+ /** 单选模式的草稿值 */
+ modelValue?: Date | null
+ /** 范围起点草稿 */
+ start?: Date | null
+ /** 范围终点草稿 */
+ end?: Date | null
+ /** 范围预览终点 */
+ hoverValue?: Date | null
+ /** 单选无值时的时间初值 */
+ timeDefault?: DateTimeDefault
+ /** 由 analyzeDateFormat 产出的格式配置 */
+ pickerConfig?: DateFormatConfig
+ /** 禁用日期判定 */
+ disabledDate?: (date: Date) => boolean
+}
+
+/** 年 / 月选择器视图模式 */
+export type CalendarMode = 'date' | 'year' | 'month' | 'time'
diff --git a/code/g3soft-libs/packages/ui/src/drawer/Drawer.vue b/code/g3soft-libs/packages/ui/src/drawer/Drawer.vue
new file mode 100644
index 00000000..0bf26614
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/drawer/Drawer.vue
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/drawer/index.ts b/code/g3soft-libs/packages/ui/src/drawer/index.ts
new file mode 100644
index 00000000..0375456e
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/drawer/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Drawer } from './Drawer.vue'
+export type { DrawerPlacement, DrawerProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/drawer/types.ts b/code/g3soft-libs/packages/ui/src/drawer/types.ts
new file mode 100644
index 00000000..2b7ccfb8
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/drawer/types.ts
@@ -0,0 +1,31 @@
+import type { PopupContainer } from '../_utils/global-config'
+
+/** 滑出方向 */
+export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom'
+
+export interface DrawerProps {
+ /** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
+ open?: boolean
+ /** 非受控时的初始打开状态 */
+ defaultOpen?: boolean
+ /** 标题文字 */
+ title?: string
+ /** 滑出方向 */
+ placement?: DrawerPlacement
+ /** 面板宽度(left / right 方向生效,px 数字或 CSS 宽度字符串) */
+ width?: number | string
+ /** 面板高度(top / bottom 方向生效,px 数字或 CSS 高度字符串) */
+ height?: number | string
+ /** 是否显示遮罩 */
+ mask?: boolean
+ /** 点击遮罩关闭 */
+ maskClosable?: boolean
+ /** Esc 关闭 */
+ keyboard?: boolean
+ /** 显示右上角关闭按钮 */
+ closable?: boolean
+ /** 允许拖动面板边缘调整尺寸(左右方向改宽度,上下方向改高度) */
+ resizable?: boolean
+ /** 弹层挂载容器;不传时取 ConfigProvider.popupContainer,再退回 body */
+ popupContainer?: PopupContainer
+}
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.test.ts b/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.test.ts
new file mode 100644
index 00000000..a11885e1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.test.ts
@@ -0,0 +1,219 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import { nextTick } from 'vue'
+import G3Dropdown from './Dropdown.vue'
+import G3DropdownItem from './DropdownItem.vue'
+
+/**
+ * 回归重点:`编辑` 的文案落在默认插槽里
+ * (编译器把组件子节点包成 `{ default: () => [TextVNode] }`),
+ * 早期只读 `label` prop,结果菜单渲染成一排空白项;
+ * 另外 `` 的空值布尔属性也会被判 falsy 丢掉。
+ *
+ * 注意:浮层 Teleport 到 body,测试之间会累积,必须每个用例后清空 body,
+ * 并且只取「最后一个」菜单(即当前用例的)。
+ */
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+function lastMenu(): HTMLElement | null {
+ const menus = document.body.querySelectorAll('.g3-dropdown__menu')
+ return (menus.item(menus.length - 1) as HTMLElement) ?? null
+}
+
+function mountDropdown(template: string) {
+ return mount({
+ components: { G3Dropdown, G3DropdownItem },
+ template,
+ })
+}
+
+const DECLARATIVE = `
+
+
+ 编辑
+
+ 删除
+ 禁用项
+
+`
+
+async function openDropdown(wrapper: ReturnType) {
+ await wrapper.find('.trigger').trigger('click')
+ await nextTick()
+ return lastMenu()
+}
+
+describe('Dropdown 声明式子项', () => {
+ it('插槽文本会作为菜单文案渲染(不再出现空白项)', async () => {
+ const wrapper = mountDropdown(DECLARATIVE)
+ const menu = await openDropdown(wrapper)
+
+ expect(menu).not.toBeNull()
+ const labels = [...menu!.querySelectorAll('.g3-dropdown__item-label')].map((el) => el.textContent)
+ expect(labels).toEqual(['编辑', '删除', '禁用项'])
+ })
+
+ it('divider 渲染成分隔线而不是空项', async () => {
+ const wrapper = mountDropdown(DECLARATIVE)
+ const menu = await openDropdown(wrapper)
+
+ expect(menu!.querySelectorAll('.g3-dropdown__divider')).toHaveLength(1)
+ expect(menu!.querySelectorAll('.g3-dropdown__item')).toHaveLength(3)
+ })
+
+ it('danger / disabled 反映到类名与 aria', async () => {
+ const wrapper = mountDropdown(DECLARATIVE)
+ const menu = await openDropdown(wrapper)
+ const items = [...menu!.querySelectorAll('.g3-dropdown__item')]
+
+ expect(items[0].className).not.toContain('is-item-danger')
+ expect(items[1].className).toContain('is-item-danger')
+ expect(items[2].className).toContain('is-item-disabled')
+ expect(items[2].getAttribute('aria-disabled')).toBe('true')
+ })
+
+ /**
+ * 用 v-model:open + 本地记录点击载荷来断言:
+ * wrapper 是外层匿名组件,事件其实由 G3Dropdown 派发,
+ * 直接断言 wrapper.emitted() 容易被原生事件冒泡干扰,走状态更可靠。
+ */
+ function mountStateful() {
+ return mount({
+ components: { G3Dropdown, G3DropdownItem },
+ data() {
+ return { open: false, clicked: null as null | { key?: unknown; item?: { danger?: boolean } } }
+ },
+ template: `
+
+
+ 编辑
+
+ 删除
+ 禁用项
+
+ `,
+ })
+ }
+
+ type StatefulWrapper = ReturnType
+
+ async function openStateful(wrapper: StatefulWrapper) {
+ await wrapper.find('.trigger').trigger('click')
+ await nextTick()
+ const menu = lastMenu()
+ expect(menu).not.toBeNull()
+ return { menu: menu!, vm: wrapper.vm as unknown as { open: boolean; clicked: unknown } }
+ }
+
+ it('点击可点项后请求关闭菜单', async () => {
+ const wrapper = mountStateful()
+ const { menu, vm } = await openStateful(wrapper)
+ expect(vm.open).toBe(true)
+
+ ;(menu.querySelector('.g3-dropdown__item') as HTMLElement).click()
+ await nextTick()
+
+ expect(vm.open).toBe(false)
+ })
+
+ it('点击项时回传 key 与 item(供调用方分发动作)', async () => {
+ const wrapper = mountStateful()
+ const { menu, vm } = await openStateful(wrapper)
+ const deleteItem = [...menu.querySelectorAll('.g3-dropdown__item')][1] as HTMLElement
+
+ deleteItem.click()
+ await nextTick()
+
+ const payload = vm.clicked as { key: string; item: { danger?: boolean } }
+ expect(payload.key).toBe('del')
+ expect(payload.item.danger).toBe(true)
+ })
+
+ it('禁用项点击既不回传点击也不关闭菜单', async () => {
+ const wrapper = mountStateful()
+ const { menu, vm } = await openStateful(wrapper)
+ const disabled = [...menu.querySelectorAll('.g3-dropdown__item')][2] as HTMLElement
+
+ disabled.click()
+ await nextTick()
+
+ expect(vm.clicked).toBeNull()
+ expect(vm.open).toBe(true)
+ })
+})
+
+describe('Dropdown items 数据源', () => {
+ it('items 优先于插槽,支持分组标签与子菜单', async () => {
+ const wrapper = mount({
+ components: { G3Dropdown },
+ data() {
+ return {
+ items: [
+ { type: 'label' as const, label: '分组' },
+ { key: 'copy', label: '复制' },
+ { key: 'more', label: '更多', children: [{ key: 'child', label: '子项' }] },
+ ],
+ }
+ },
+ template: `
+
+
+ 逃生舱
+
+ `,
+ })
+
+ await wrapper.find('.trigger').trigger('click')
+ await nextTick()
+
+ const menu = lastMenu()!
+ expect(menu.querySelector('.g3-dropdown__label')?.textContent?.trim()).toBe('分组')
+ expect(menu.querySelector('.g3-dropdown__item-caret')).not.toBeNull()
+ // items 存在时不渲染 #menu 逃生舱
+ expect(menu.querySelector('.fallback')).toBeNull()
+ })
+
+ it('没有 items 也没有声明式项时回退到 #menu 逃生舱', async () => {
+ const wrapper = mount({
+ components: { G3Dropdown },
+ template: `
+
+
+
+
+
+
+ `,
+ })
+
+ await wrapper.find('.trigger').trigger('click')
+ await nextTick()
+
+ const menu = lastMenu()!
+ expect(menu.querySelector('.custom')).not.toBeNull()
+ expect(menu.querySelectorAll('.g3-dropdown__item')).toHaveLength(0)
+ })
+})
+
+describe('Dropdown 键盘导航', () => {
+ it('下键高亮第一个可点项,并跳过禁用项', async () => {
+ const wrapper = mountDropdown(DECLARATIVE)
+ const menu = (await openDropdown(wrapper))!
+
+ menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+ await nextTick()
+ expect(menu.querySelector('.is-item-active')?.textContent).toContain('编辑')
+
+ menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+ await nextTick()
+ // 第二个可点项是「删除」,被禁用的项不参与
+ expect(menu.querySelector('.is-item-active')?.textContent).toContain('删除')
+
+ menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+ await nextTick()
+ // 循环回第一项,「禁用项」永远不会被高亮
+ expect(menu.querySelector('.is-item-active')?.textContent).toContain('编辑')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.vue b/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.vue
new file mode 100644
index 00000000..ddbbbfac
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/Dropdown.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/DropdownItem.vue b/code/g3soft-libs/packages/ui/src/dropdown/DropdownItem.vue
new file mode 100644
index 00000000..5140f4bd
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/DropdownItem.vue
@@ -0,0 +1,39 @@
+
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/DropdownMenu.vue b/code/g3soft-libs/packages/ui/src/dropdown/DropdownMenu.vue
new file mode 100644
index 00000000..2900d3ba
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/DropdownMenu.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+ {{ item.shortcut }}
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/context.ts b/code/g3soft-libs/packages/ui/src/dropdown/context.ts
new file mode 100644
index 00000000..37bc461f
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/context.ts
@@ -0,0 +1,12 @@
+import type { InjectionKey, Ref } from 'vue'
+import type { DropdownItemData } from './types'
+
+/** 主 Dropdown 注入给菜单 / 子菜单的选中回调 */
+export const dropdownSelectKey: InjectionKey<((item: DropdownItemData) => void) | null> = Symbol(
+ 'g3-dropdown-select',
+)
+
+/** 主 Dropdown 注入的键盘高亮索引;子菜单不参与联动 */
+export const dropdownActiveIndexKey: InjectionKey[ | null> = Symbol(
+ 'g3-dropdown-active',
+)
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/index.ts b/code/g3soft-libs/packages/ui/src/dropdown/index.ts
new file mode 100644
index 00000000..08d060af
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/index.ts
@@ -0,0 +1,8 @@
+export { default as G3Dropdown } from './Dropdown.vue'
+export { default as G3DropdownItem } from './DropdownItem.vue'
+export type {
+ DropdownClickPayload,
+ DropdownItemData,
+ DropdownProps,
+ DropdownTrigger,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/dropdown/types.ts b/code/g3soft-libs/packages/ui/src/dropdown/types.ts
new file mode 100644
index 00000000..ae0ac751
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/dropdown/types.ts
@@ -0,0 +1,58 @@
+import type { Component, VNode } from 'vue'
+import type { Placement } from '../_utils/position'
+
+/** 触发方式:下拉菜单只支持这三种 */
+export type DropdownTrigger = 'click' | 'hover' | 'contextmenu'
+
+/** 菜单项数据 */
+export interface DropdownItemData {
+ /** 标识,点击时随 click 事件回传 */
+ key?: string | number
+ /** 菜单文本 */
+ label?: string
+ /** 图标:组件或 VNode */
+ icon?: Component | VNode
+ /** 危险项(红色) */
+ danger?: boolean
+ /** 禁用项 */
+ disabled?: boolean
+ /** 快捷键提示文字 */
+ shortcut?: string
+ /** 渲染为分隔线(其余属性忽略) */
+ divider?: boolean
+ /** type='label' 时渲染为分组标签 */
+ type?: 'label'
+ /** 子菜单 */
+ children?: DropdownItemData[]
+ /** 项自带点击回调,命中时优先执行 */
+ onClick?: (item: DropdownItemData) => void
+}
+
+/** 菜单项点击事件载荷 */
+export interface DropdownClickPayload {
+ key?: string | number
+ item: DropdownItemData
+}
+
+export interface DropdownProps {
+ /** 菜单项数组;不传时若默认插槽含 G3DropdownItem 则由其收集渲染,否则用 #menu 插槽 */
+ items?: DropdownItemData[]
+ /** 受控打开状态;不传则非受控 */
+ open?: boolean
+ /** 非受控时的初始打开状态 */
+ defaultOpen?: boolean
+ /** 触发方式 */
+ trigger?: DropdownTrigger
+ /** 菜单浮层方位 */
+ placement?: Placement
+ /** 禁用态 */
+ disabled?: boolean
+ /** hover 触发打开延迟(ms) */
+ mouseEnterDelay?: number
+ /** hover 触发关闭延迟(ms) */
+ mouseLeaveDelay?: number
+ /** 与触发器的间距(px) */
+ offset?: number
+ /** 零包裹模式(默认开启):把事件注入到触发器单根 vnode,不再包 span */
+ unwrapped?: boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/form/Form.vue b/code/g3soft-libs/packages/ui/src/form/Form.vue
new file mode 100644
index 00000000..90077ed0
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/Form.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/form/FormGroup.vue b/code/g3soft-libs/packages/ui/src/form/FormGroup.vue
new file mode 100644
index 00000000..6439e051
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/FormGroup.vue
@@ -0,0 +1,184 @@
+
+
+
+ ]
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/form/FormItem.test.ts b/code/g3soft-libs/packages/ui/src/form/FormItem.test.ts
new file mode 100644
index 00000000..08305566
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/FormItem.test.ts
@@ -0,0 +1,158 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import G3Form from './Form.vue'
+import G3FormItem from './FormItem.vue'
+import G3Input from '../input/Input.vue'
+
+/**
+ * 回归重点:`FormItemProps.name` 曾经被声明成必填,导致
+ * - 只当布局容器用的 FormItem(不传 name)在控制台刷「Missing required prop」
+ * - 还会往 Form 注册一个字段名是 "undefined" 的假字段,污染全量校验
+ */
+const COMPONENTS = { G3Form, G3FormItem, G3Input }
+
+function mountForm(options: { withName?: boolean } = {}) {
+ const withName = options.withName ?? true
+ return mount({
+ components: COMPONENTS,
+ data() {
+ return {
+ model: { name: '' },
+ rules: { name: [{ required: true }] },
+ }
+ },
+ template: `
+
+
+
+
+ 占位
+
+ `,
+ })
+}
+
+function formOf(wrapper: ReturnType) {
+ return wrapper.findComponent({ name: 'G3Form' }).vm as unknown as {
+ validateAll: () => Promise>
+ }
+}
+
+describe('FormItem 缺省 name(纯布局项)', () => {
+ it('不传 name 时不报 required 警告', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const wrapper = mountForm({ withName: false })
+
+ const warnings = warn.mock.calls.map((call) => String(call[0])).join('\n')
+ expect(warnings).not.toContain('Missing required prop')
+ expect(wrapper.find('.plain').exists()).toBe(true)
+ warn.mockRestore()
+ })
+
+ it('不传 name 时不会往 Form 注册假字段(全量校验只报真实字段)', async () => {
+ const wrapper = mountForm({ withName: false })
+ const errors = await formOf(wrapper).validateAll()
+
+ expect(errors).toHaveLength(0)
+
+ const withField = mountForm()
+ const fieldErrors = await formOf(withField).validateAll()
+ expect(fieldErrors.map((item) => item.name)).toEqual(['name'])
+ })
+
+ it('纯布局项不渲染 data-field', () => {
+ const wrapper = mountForm()
+ const items = wrapper.findAll('.g3-form-item')
+ expect(items[0].attributes('data-field')).toBe('name')
+ expect(items[1].attributes('data-field')).toBeUndefined()
+ })
+})
+
+describe('FormItem 校验与提示', () => {
+ it('不传 name 的项即使有 rules 也不参与校验', async () => {
+ const wrapper = mount({
+ components: COMPONENTS,
+ data() {
+ return { model: { a: '' } }
+ },
+ template: `
+
+ x
+
+ `,
+ })
+
+ const errors = await (wrapper.findComponent({ name: 'G3Form' }).vm as unknown as {
+ validateAll: () => Promise
+ }).validateAll()
+ expect(errors).toHaveLength(0)
+ })
+
+ it('validateAll 后渲染错误文案并标记 error 状态', async () => {
+ const wrapper = mountForm()
+ await formOf(wrapper).validateAll()
+ await nextTick()
+
+ const item = wrapper.findAll('.g3-form-item')[0]
+ expect(item.classes()).toContain('is-error')
+ expect(item.find('.g3-form-item__help').text()).toBe('名称不能为空')
+ expect(item.find('.g3-form-item__help').attributes('role')).toBe('alert')
+ })
+
+ it('填值后再次校验通过,错误提示消失', async () => {
+ const wrapper = mountForm()
+ await formOf(wrapper).validateAll()
+ await nextTick()
+ expect(wrapper.find('.g3-form-item__help').exists()).toBe(true)
+
+ await wrapper.find('input').setValue('张三')
+ await formOf(wrapper).validateAll()
+ await nextTick()
+
+ expect(wrapper.find('.g3-form-item__help').exists()).toBe(false)
+ expect(wrapper.findAll('.g3-form-item')[0].classes()).not.toContain('is-error')
+ })
+
+ it('必填标记由规则推导;Form 关闭 requiredMark 后不显示星号', () => {
+ const wrapper = mountForm()
+ expect(wrapper.find('.g3-form-item__required').exists()).toBe(true)
+
+ const noMark = mount({
+ components: COMPONENTS,
+ data() {
+ return { model: { name: '' }, rules: { name: [{ required: true }] } }
+ },
+ template: `
+
+
+
+ `,
+ })
+ expect(noMark.find('.g3-form-item__required').exists()).toBe(false)
+ })
+
+ it('extra 在没有错误时展示,出错后让位给错误信息', async () => {
+ const wrapper = mount({
+ components: COMPONENTS,
+ data() {
+ return { model: { name: '' }, rules: { name: [{ required: true }] } }
+ },
+ template: `
+
+
+
+ `,
+ })
+
+ expect(wrapper.find('.g3-form-item__extra').text()).toBe('辅助说明')
+
+ await (wrapper.findComponent({ name: 'G3Form' }).vm as unknown as {
+ validateAll: () => Promise
+ }).validateAll()
+ await nextTick()
+
+ expect(wrapper.find('.g3-form-item__extra').exists()).toBe(false)
+ expect(wrapper.find('.g3-form-item__help').text()).toBe('名称不能为空')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/form/FormItem.vue b/code/g3soft-libs/packages/ui/src/form/FormItem.vue
new file mode 100644
index 00000000..6387d731
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/FormItem.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ helpText }}
+
+
{{ merged.extra }}
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/form/context.ts b/code/g3soft-libs/packages/ui/src/form/context.ts
new file mode 100644
index 00000000..6d8ef798
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/context.ts
@@ -0,0 +1,37 @@
+import type { ComputedRef, InjectionKey } from 'vue'
+import type { FormLabelAlign, FormLayout, FormRule, FormValidateTrigger } from './types'
+
+/** FormItem 注册给 Form 的控制器 */
+export interface FormFieldController {
+ /** 立即校验,返回错误消息数组(空数组表示通过) */
+ validate: () => Promise
+ /** 清空错误与触碰标记 */
+ clearValidation: () => void
+}
+
+/** Form 通过 provide 下发的上下文 */
+export interface FormContext {
+ model: ComputedRef>
+ rules: ComputedRef>
+ layout: ComputedRef
+ labelAlign: ComputedRef
+ labelWidth: ComputedRef
+ /** 字段跨列计算用的列数 */
+ columns: ComputedRef
+ requiredMark: ComputedRef
+ validateTrigger: ComputedRef
+ registerField: (name: string | number, field: FormFieldController) => void
+ unregisterField: (name: string | number) => void
+}
+
+/** FormGroup 通过 provide 下发的组上下文(覆盖外层 Form 的列数与布局) */
+export interface FormGroupContext {
+ /** 组内列数;undefined 表示未指定,由 FormItem 回落到外层 Form 的 columns */
+ columns: ComputedRef
+ layout: ComputedRef
+ labelWidth: ComputedRef
+}
+
+export const formKey: InjectionKey = Symbol('g3-form')
+
+export const formGroupKey: InjectionKey = Symbol('g3-form-group')
diff --git a/code/g3soft-libs/packages/ui/src/form/form-utils.test.ts b/code/g3soft-libs/packages/ui/src/form/form-utils.test.ts
new file mode 100644
index 00000000..5684d3b5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/form-utils.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { fluidColumnGap, normalizeGap } from './form-utils'
+
+describe('normalizeGap', () => {
+ it('数字表示行列等距', () => {
+ expect(normalizeGap(8)).toEqual({ row: 8, col: 8 })
+ expect(normalizeGap(0)).toEqual({ row: 0, col: 0 })
+ })
+
+ it('对象原样返回,未传时给空对象', () => {
+ expect(normalizeGap({ row: 4 })).toEqual({ row: 4 })
+ expect(normalizeGap({ col: 12 })).toEqual({ col: 12 })
+ expect(normalizeGap(undefined)).toEqual({})
+ })
+})
+
+describe('fluidColumnGap(列间距防溢出)', () => {
+ it('取 min(px, 100% / (2N)),去掉多余的 0', () => {
+ expect(fluidColumnGap(16, 3)).toBe('min(16px, 16.667%)')
+ expect(fluidColumnGap(16, 2)).toBe('min(16px, 25%)')
+ expect(fluidColumnGap(16, 5)).toBe('min(16px, 10%)')
+ expect(fluidColumnGap(16, 8)).toBe('min(16px, 6.25%)')
+ expect(fluidColumnGap(24, 4)).toBe('min(24px, 12.5%)')
+ })
+
+ it('单列或未配置间距时不产出 gap', () => {
+ expect(fluidColumnGap(16, 1)).toBeUndefined()
+ expect(fluidColumnGap(16, 0)).toBeUndefined()
+ expect(fluidColumnGap(undefined, 3)).toBeUndefined()
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/form/form-utils.ts b/code/g3soft-libs/packages/ui/src/form/form-utils.ts
new file mode 100644
index 00000000..134360c3
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/form-utils.ts
@@ -0,0 +1,26 @@
+import type { FormGap } from './types'
+
+export interface NormalizedGap {
+ row?: number
+ col?: number
+}
+
+/** gap 归一化:数字 → { row, col }(行 / 列等距),对象原样返回,空值返回 {} */
+export function normalizeGap(gap?: FormGap): NormalizedGap {
+ if (typeof gap === 'number') return { row: gap, col: gap }
+ return gap ?? {}
+}
+
+/**
+ * 列间距防溢出钳制(宫格嵌套场景)。
+ *
+ * grid 的 column-gap 是固定值且不可压缩:N 列宫格有 N-1 个 gap,
+ * 窄容器下轨道可被 minmax(0, 1fr) 压到 0,gap 压不动,总量超出容器宽即横向溢出。
+ * 返回 `min(px, 100% / (2N))`:gap 总量最多占容器约 50%,任何宽度下不会溢出;
+ * 容器够宽时取 px 上限,与固定值视觉一致。
+ */
+export function fluidColumnGap(col: number | undefined, columns: number): string | undefined {
+ if (col == null || !columns || columns <= 1) return undefined
+ const maxPercent = (100 / (columns * 2)).toFixed(3).replace(/\.?0+$/, '')
+ return `min(${col}px, ${maxPercent}%)`
+}
diff --git a/code/g3soft-libs/packages/ui/src/form/index.ts b/code/g3soft-libs/packages/ui/src/form/index.ts
new file mode 100644
index 00000000..c8f14d07
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/index.ts
@@ -0,0 +1,19 @@
+export { default as G3Form } from './Form.vue'
+export { default as G3FormItem } from './FormItem.vue'
+export { default as G3FormGroup } from './FormGroup.vue'
+export { formGroupKey, formKey } from './context'
+export type { FormContext, FormFieldController, FormGroupContext } from './context'
+export { fluidColumnGap, normalizeGap } from './form-utils'
+export { isEmptyValue, validateRules } from './validate'
+export type { ValidateRulesOptions } from './validate'
+export type {
+ FormGap,
+ FormGroupProps,
+ FormItemProps,
+ FormItemStatus,
+ FormLabelAlign,
+ FormLayout,
+ FormProps,
+ FormRule,
+ FormValidateTrigger,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/form/types.ts b/code/g3soft-libs/packages/ui/src/form/types.ts
new file mode 100644
index 00000000..916423a9
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/types.ts
@@ -0,0 +1,123 @@
+/** 表单布局:horizontal(label 在左)| vertical(label 在上) */
+export type FormLayout = 'horizontal' | 'vertical'
+
+/** label 文本对齐 */
+export type FormLabelAlign = 'left' | 'right'
+
+/** 校验时机:change(值变化即校验)| blur(失焦时校验) */
+export type FormValidateTrigger = 'change' | 'blur'
+
+/** 校验状态 */
+export type FormItemStatus = '' | 'error'
+
+/** 单条校验规则 */
+export interface FormRule {
+ /** 必填 */
+ required?: boolean
+ /** 类型校验 */
+ type?: 'string' | 'number' | 'integer' | 'email' | 'url'
+ /** 正则校验 */
+ pattern?: RegExp
+ /** 长度/数值必须等于 */
+ len?: number
+ /** 长度/数值下限 */
+ min?: number
+ /** 长度/数值上限 */
+ max?: number
+ /** 错误文案;传函数则在每次校验时求值 */
+ message?: string | (() => string)
+ /**
+ * 自定义校验:
+ * - 返回 true / undefined / null → 通过
+ * - 返回 false → 使用 message 或默认文案
+ * - 返回非空字符串 → 该字符串作为错误消息
+ */
+ validator?: (value: unknown, rule: FormRule) => unknown
+}
+
+/** 表单项间距:单个数字表示行 / 列等距,或 { row, col } 分别控制 */
+export type FormGap = number | { row?: number; col?: number }
+
+export interface FormProps {
+ /** 表单数据对象,字段值按 FormItem 的 name 读取 */
+ model: Record
+ /** 表单级规则:{ [name]: FormRule[] },FormItem 级 rules 优先级更高 */
+ rules?: Record
+ /** 布局;不传时回落全局默认值 */
+ layout?: FormLayout
+ /** label 文本对齐方式;不传时回落全局默认值 */
+ labelAlign?: FormLabelAlign
+ /** label 宽度(仅横向布局生效) */
+ labelWidth?: string | number
+ /** 每行字段列数(>1 时启用网格布局,字段按顺序从左到右自动换行) */
+ columns?: number
+ /** 仅影响 FormItem 的 span 计算,不让 Form 自身变 grid(外部栅格 + FormItem 跨列场景) */
+ spanColumns?: number
+ /** 表单最大宽度(Number 按 px 处理) */
+ maxWidth?: string | number
+ /** 必填红星标记 */
+ requiredMark?: boolean
+ /** 校验时机;不传时回落全局默认值 */
+ validateTrigger?: FormValidateTrigger
+ /** 表单项间距 */
+ gap?: FormGap
+}
+
+export interface FormItemProps {
+ /**
+ * 字段名,对应 Form.model 的 key。
+ * 不传时该项只作布局容器:不注册进 Form、不参与校验、也不回填错误提示。
+ */
+ name?: string | number
+ /** 标签文本 */
+ label?: string
+ /** 单项规则;未提供时回退到 Form 级 rules 中同名规则 */
+ rules?: FormRule[]
+ /** 必填标记;未显式指定时从 rules 中推导 */
+ required?: boolean
+ /** 辅助说明文字(置于控件下方) */
+ extra?: string
+ /** 固定提示(校验错误时优先展示错误信息) */
+ help?: string
+ /** 单项 label 对齐方式覆盖 */
+ labelAlign?: FormLabelAlign
+ /** 单项 label 宽度覆盖(仅横向布局生效) */
+ labelWidth?: string | number
+ /** 网格布局下跨列数(不超过所在容器的列数,默认 1) */
+ span?: number
+ /** 行首标记:本字段是所在行的第一个字段(网格布局下强制从第 1 列开始) */
+ lineStart?: boolean
+ /** 外部手动指定校验状态 */
+ validateStatus?: FormItemStatus
+ /** 布局覆盖;一般由 Form 下发,独立使用时可自行指定 */
+ layout?: FormLayout
+ /** 校验时机覆盖;一般由 Form 下发 */
+ validateTrigger?: FormValidateTrigger
+}
+
+export interface FormGroupProps {
+ /** 受控展开态(v-model);不传时由 defaultExpanded 决定 */
+ modelValue?: boolean
+ /** 是否可折叠:点击标题行切换展开 / 收起 */
+ collapsible?: boolean
+ /** 非受控时的初始展开态 */
+ defaultExpanded?: boolean
+ /**
+ * 组内列数;不传时继承外层 Form 的 columns。
+ * 传 1 可让分组的字段强制单列排布。
+ */
+ columns?: number
+ /** 组内布局(覆盖外层 Form 的 layout) */
+ layout?: FormLayout
+ /** 组内 label 宽度(覆盖外层 Form) */
+ labelWidth?: string | number
+ /** 组内表单项间距(覆盖外层 Form 的 gap) */
+ gap?: FormGap
+ /**
+ * 外层宫格占格数;放在多列 Form 里时生效。
+ * 不传时占满整行(分组通常自成一行)。
+ */
+ span?: number
+ /** 组标题 */
+ title?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/form/validate.test.ts b/code/g3soft-libs/packages/ui/src/form/validate.test.ts
new file mode 100644
index 00000000..a27c2760
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/validate.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { zhCN } from '../locale'
+import type { FormRule } from './types'
+import { isEmptyValue, validateRules } from './validate'
+
+const messages = zhCN.form
+
+function run(value: unknown, rules: FormRule[], label = '用户名') {
+ return validateRules({ label, value, rules, messages })
+}
+
+describe('isEmptyValue', () => {
+ it('undefined / null / 空串视为空,0 与 false 不算空', () => {
+ expect(isEmptyValue(undefined)).toBe(true)
+ expect(isEmptyValue(null)).toBe(true)
+ expect(isEmptyValue('')).toBe(true)
+ expect(isEmptyValue(0)).toBe(false)
+ expect(isEmptyValue(false)).toBe(false)
+ expect(isEmptyValue([])).toBe(false)
+ })
+})
+
+describe('validateRules 静态约束', () => {
+ it('没有规则时直接通过', async () => {
+ await expect(run('任意值', [])).resolves.toEqual([])
+ })
+
+ it('required:空值报「{label}不能为空」,且其余约束不再执行', async () => {
+ const rules: FormRule[] = [{ required: true, type: 'email' }, { min: 6 }]
+ await expect(run('', rules)).resolves.toEqual(['用户名不能为空'])
+ await expect(run(undefined, rules)).resolves.toEqual(['用户名不能为空'])
+ })
+
+ it('非必填约束只在有值时执行:空值跳过,避免与必填重复报错', async () => {
+ await expect(run('', [{ type: 'email' }, { min: 6 }])).resolves.toEqual([])
+ })
+
+ it('label 为空时收掉多余空白,不留前导空格', async () => {
+ await expect(run('', [{ required: true }], '')).resolves.toEqual(['不能为空'])
+ })
+
+ it('type:number / integer 接受数字字符串,email / url 按格式判定', async () => {
+ await expect(run('12.5', [{ type: 'number' }])).resolves.toEqual([])
+ await expect(run('abc', [{ type: 'number' }])).resolves.toEqual(['用户名格式不正确'])
+ await expect(run('1.5', [{ type: 'integer' }])).resolves.toEqual(['用户名格式不正确'])
+ await expect(run('a@b.com', [{ type: 'email' }])).resolves.toEqual([])
+ await expect(run('a@b', [{ type: 'email' }])).resolves.toEqual(['用户名格式不正确'])
+ await expect(run('https://a.com', [{ type: 'url' }])).resolves.toEqual([])
+ await expect(run('ftp://a.com', [{ type: 'url' }])).resolves.toEqual(['用户名格式不正确'])
+ })
+
+ it('pattern 命中失败给出格式文案', async () => {
+ await expect(run('abc', [{ pattern: /^\d+$/ }])).resolves.toEqual(['用户名格式不正确'])
+ await expect(run('123', [{ pattern: /^\d+$/ }])).resolves.toEqual([])
+ })
+
+ it('len / min / max 按「长度」判定:字符串取字符数、数组取项数、数字取数值', async () => {
+ await expect(run('abc', [{ len: 3 }])).resolves.toEqual([])
+ await expect(run('ab', [{ len: 3 }])).resolves.toEqual(['用户名长度必须为 3'])
+ await expect(run('ab', [{ min: 3 }])).resolves.toEqual(['用户名长度不能少于 3'])
+ await expect(run('abcd', [{ max: 3 }])).resolves.toEqual(['用户名长度不能超过 3'])
+ await expect(run(['a', 'b'], [{ min: 3 }])).resolves.toEqual(['用户名长度不能少于 3'])
+ await expect(run(5, [{ min: 10 }])).resolves.toEqual(['用户名长度不能少于 10'])
+ })
+
+ it('一个规则可同时带多个约束,错误全部收集;多规则按顺序拼接', async () => {
+ await expect(run('ab', [{ min: 3, max: 1 }])).resolves.toEqual([
+ '用户名长度不能少于 3',
+ '用户名长度不能超过 1',
+ ])
+ await expect(run('ab', [{ min: 3 }, { pattern: /^\d+$/ }])).resolves.toEqual([
+ '用户名长度不能少于 3',
+ '用户名格式不正确',
+ ])
+ })
+
+ it('message 支持字符串与函数,优先于语言包模板', async () => {
+ await expect(run('', [{ required: true, message: '必须填写用户名' }])).resolves.toEqual([
+ '必须填写用户名',
+ ])
+ await expect(run('', [{ required: true, message: () => '动态文案' }])).resolves.toEqual([
+ '动态文案',
+ ])
+ })
+})
+
+describe('validateRules 自定义 validator', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('返回 true / undefined / null 视为通过', async () => {
+ await expect(run('x', [{ validator: () => true }])).resolves.toEqual([])
+ await expect(run('x', [{ validator: () => undefined }])).resolves.toEqual([])
+ await expect(run('x', [{ validator: () => null }])).resolves.toEqual([])
+ })
+
+ it('返回 false 用 invalid 文案,返回字符串则作为错误消息', async () => {
+ await expect(run('x', [{ validator: () => false }])).resolves.toEqual(['用户名校验未通过'])
+ await expect(run('x', [{ validator: () => '自定义错误' }])).resolves.toEqual(['自定义错误'])
+ })
+
+ it('支持返回 Promise', async () => {
+ await expect(run('x', [{ validator: async () => false }])).resolves.toEqual([
+ '用户名校验未通过',
+ ])
+ })
+
+ it('validator 抛异常视为校验失败,并保留静态约束的结果', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const rules: FormRule[] = [{ min: 3 }, { validator: () => { throw new Error('boom') } }]
+ await expect(run('a', rules)).resolves.toEqual(['用户名长度不能少于 3', '用户名校验未通过'])
+ })
+
+ it('validator 与静态约束同时失败时结果合并', async () => {
+ const rules: FormRule[] = [{ min: 3, validator: () => '再补一条' }]
+ await expect(run('a', rules)).resolves.toEqual(['用户名长度不能少于 3', '再补一条'])
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/form/validate.ts b/code/g3soft-libs/packages/ui/src/form/validate.ts
new file mode 100644
index 00000000..f647031e
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/form/validate.ts
@@ -0,0 +1,152 @@
+import type { Locale } from '../locale'
+import type { FormRule } from './types'
+
+/**
+ * 轻量表单校验纯函数。
+ *
+ * 按规则逐条执行,返回 Promise(空数组表示通过)。
+ * 规则支持:required / type / pattern / len / min / max / validator,
+ * 一个规则对象可同时携带多个约束,错误消息全部收集后返回。
+ */
+
+/** 表单语义下的空值:undefined / null / 空字符串 */
+export function isEmptyValue(value: unknown): boolean {
+ return value === undefined || value === null || value === ''
+}
+
+/** 类型校验器:number / integer 允许数字字符串(如 Input number 模式提交前的字符串中间态) */
+const TYPE_VALIDATORS: Record boolean> = {
+ 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: unknown): number | undefined {
+ if (typeof value === 'string' || Array.isArray(value)) return value.length
+ if (typeof value === 'number') return value
+ return undefined
+}
+
+/** 替换 `{label}` / `{len}` 等占位符;label 为空时收掉多余空白 */
+function format(template: string, params: Record): string {
+ let output = template
+ for (const [key, value] of Object.entries(params)) {
+ output = output.split(`{${key}}`).join(value == null ? '' : String(value))
+ }
+ return output.replace(/\s{2,}/g, ' ').trim()
+}
+
+type MessageKind = keyof Locale['form']
+
+/** 解析规则错误消息:message 支持字符串或函数,未配置时回退到语言包模板 */
+function resolveMessage(
+ rule: FormRule,
+ label: string,
+ kind: MessageKind,
+ messages: Locale['form'],
+ params: Record = {},
+): string {
+ const raw = typeof rule.message === 'function' ? rule.message() : rule.message
+ if (raw != null && raw !== '') return raw
+ return format(messages[kind], { label, ...params })
+}
+
+/** 执行规则中的静态约束(required / type / pattern / len / min / max) */
+function validateStatic(
+ rule: FormRule,
+ label: string,
+ value: unknown,
+ messages: Locale['form'],
+): string[] {
+ // 必填失败:空值直接返回,其余约束不再执行
+ if (rule.required && isEmptyValue(value)) {
+ return [resolveMessage(rule, label, 'required', messages)]
+ }
+
+ // 非必填约束只在有值时执行,避免「必填」与「格式」重复报错
+ if (isEmptyValue(value)) return []
+
+ const errors: string[] = []
+
+ if (rule.type && !TYPE_VALIDATORS[rule.type]?.(value)) {
+ errors.push(resolveMessage(rule, label, 'type', messages))
+ }
+
+ if (rule.pattern && !rule.pattern.test(String(value))) {
+ errors.push(resolveMessage(rule, label, 'pattern', messages))
+ }
+
+ const length = measure(value)
+ if (length !== undefined) {
+ if (rule.len !== undefined && length !== rule.len) {
+ errors.push(resolveMessage(rule, label, 'len', messages, { len: rule.len }))
+ }
+ if (rule.min !== undefined && length < rule.min) {
+ errors.push(resolveMessage(rule, label, 'min', messages, { min: rule.min }))
+ }
+ if (rule.max !== undefined && length > rule.max) {
+ errors.push(resolveMessage(rule, label, 'max', messages, { max: rule.max }))
+ }
+ }
+
+ return errors
+}
+
+/** 执行自定义 validator,失败 / 抛异常都归一成错误消息 */
+function runValidator(
+ rule: FormRule,
+ label: string,
+ value: unknown,
+ messages: Locale['form'],
+ staticErrors: string[],
+): Promise {
+ const fallbackError = resolveMessage(rule, label, 'invalid', messages)
+ 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: unknown) => {
+ // validator 抛出的异常视为校验失败,避免整表提交被中断
+ console.error('[g3-form] validator 执行异常:', error)
+ return [...staticErrors, fallbackError]
+ })
+}
+
+export interface ValidateRulesOptions {
+ /** 字段标签,用于拼接错误消息 */
+ label?: string
+ /** 字段当前值 */
+ value?: unknown
+ /** 规则列表 */
+ rules?: FormRule[]
+ /** 校验文案模板(来自语言包) */
+ messages: Locale['form']
+}
+
+/** 执行一组规则,返回错误消息数组(空数组表示通过) */
+export function validateRules(options: ValidateRulesOptions): Promise {
+ const { label = '', value, rules = [], messages } = options
+ if (!Array.isArray(rules) || rules.length === 0) return Promise.resolve([])
+
+ return Promise.all(
+ rules.map((rule) => {
+ const staticErrors = validateStatic(rule, label, value, messages)
+ if (typeof rule?.validator === 'function') {
+ return runValidator(rule, label, value, messages, staticErrors)
+ }
+ return Promise.resolve(staticErrors)
+ }),
+ ).then((groups) => groups.flat())
+}
diff --git a/code/g3soft-libs/packages/ui/src/grid/Col.vue b/code/g3soft-libs/packages/ui/src/grid/Col.vue
new file mode 100644
index 00000000..bbbb6172
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/grid/Col.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/grid/Row.vue b/code/g3soft-libs/packages/ui/src/grid/Row.vue
new file mode 100644
index 00000000..dbd8a72c
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/grid/Row.vue
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/grid/index.ts b/code/g3soft-libs/packages/ui/src/grid/index.ts
new file mode 100644
index 00000000..364a86e0
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/grid/index.ts
@@ -0,0 +1,3 @@
+export { default as G3Row } from './Row.vue'
+export { default as G3Col } from './Col.vue'
+export type { ColProps, GridGutter, RowAlign, RowJustify, RowProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/grid/types.ts b/code/g3soft-libs/packages/ui/src/grid/types.ts
new file mode 100644
index 00000000..1ae11581
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/grid/types.ts
@@ -0,0 +1,40 @@
+/** 栅格间距:数字(水平=垂直)或 [水平, 垂直] */
+export type GridGutter = number | [number, number]
+
+/** 交叉轴对齐 */
+export type RowAlign = 'top' | 'middle' | 'bottom' | 'stretch'
+
+/** 主轴对齐 */
+export type RowJustify =
+ | 'start'
+ | 'end'
+ | 'center'
+ | 'space-around'
+ | 'space-between'
+ | 'space-evenly'
+
+export interface RowProps {
+ /** 栅格间距(px):数字(水平=垂直)或 [水平, 垂直] */
+ gutter?: GridGutter
+ /** 交叉轴对齐 */
+ align?: RowAlign
+ /** 主轴对齐 */
+ justify?: RowJustify
+ /** 是否换行 */
+ wrap?: boolean
+}
+
+export interface ColProps {
+ /** 栅格占位格数 0-24 */
+ span?: number | string
+ /** 左侧偏移格数 */
+ offset?: number | string
+ /** 排序 */
+ order?: number | string
+ /** 右移格数 */
+ push?: number | string
+ /** 左移格数 */
+ pull?: number | string
+ /** flex 布局属性:数字(flex-grow)或 CSS flex 简写 */
+ flex?: number | string
+}
diff --git a/code/g3soft-libs/packages/ui/src/index.ts b/code/g3soft-libs/packages/ui/src/index.ts
index c67f819f..498d2400 100644
--- a/code/g3soft-libs/packages/ui/src/index.ts
+++ b/code/g3soft-libs/packages/ui/src/index.ts
@@ -1,14 +1,64 @@
import type { App, Plugin } from 'vue'
-import G3Button from './button/Button.vue'
-import G3ConfigProvider from './config-provider/ConfigProvider.vue'
import { setGlobalConfig, type GlobalConfig } from './_utils/global-config'
import { setBaseZIndex } from './_utils/z-index'
-export { G3Button }
-export { G3ConfigProvider }
-export type { ButtonProps, ButtonSize, ButtonType } from './button/types'
+// ---------------- 组件 ----------------
+import { G3Button } from './button'
+import { G3ButtonGroup, G3ButtonGroupSeparator } from './button-group'
+import { G3Checkbox } from './checkbox'
+import { G3CalendarPanel, G3DatePicker, G3RangePicker, G3TimeSelect } from './date'
+import { G3Drawer } from './drawer'
+import { G3Dropdown, G3DropdownItem } from './dropdown'
+import { G3Form, G3FormGroup, G3FormItem } from './form'
+import { G3Col, G3Row } from './grid'
+import { G3Input } from './input'
+import { G3MessageContainer } from './message'
+import { G3Modal } from './modal'
+import { G3NotificationContainer } from './notification'
+import { G3Pagination } from './pagination'
+import { G3Popover } from './popover'
+import { G3Radio, G3RadioGroup } from './radio'
+import { G3Scrollbar } from './scrollbar'
+import { G3Segmented } from './segmented'
+import { G3Select } from './select'
+import { G3Space } from './space'
+import { G3Spin } from './spin'
+import { G3Splitter, G3SplitterPanel } from './splitter'
+import { G3Switch } from './switch'
+import { G3TabPane, G3Tabs } from './tabs'
+import { G3Tag } from './tag'
+import { G3Tooltip } from './tooltip'
+import { G3Tree } from './tree'
-// ConfigProvider / config
+export * from './button'
+export * from './button-group'
+export * from './checkbox'
+export * from './date'
+export * from './drawer'
+export * from './dropdown'
+export * from './form'
+export * from './grid'
+export * from './input'
+export * from './message'
+export * from './modal'
+export * from './notification'
+export * from './pagination'
+export * from './popover'
+export * from './radio'
+export * from './scrollbar'
+export * from './segmented'
+export * from './select'
+export * from './space'
+export * from './spin'
+export * from './splitter'
+export * from './switch'
+export * from './tabs'
+export * from './tag'
+export * from './tooltip'
+export * from './tree'
+export * from './config-provider'
+
+// ConfigProvider / config(桶文件已导出,这里补齐历史具名出口)
export { configProviderKey, useConfig } from './config-provider/context'
export type { ConfigContext } from './config-provider/context'
export type { ConfigProviderProps, G3ThemeConfig, ThemeScope } from './config-provider/types'
@@ -17,12 +67,42 @@ export type { ConfigProviderProps, G3ThemeConfig, ThemeScope } from './config-pr
export { useNamespace, COMPILE_TIME_NAMESPACE, COMPILE_TIME_STATE_PREFIX } from './_composables/useNamespace'
export type { UseNamespaceReturn } from './_composables/useNamespace'
export { useLocale } from './_composables/useLocale'
+export { useComponentDefaults } from './_composables/useComponentDefaults'
+export { useFloating } from './_composables/useFloating'
+export type { UseFloatingOptions, UseFloatingReturn } from './_composables/useFloating'
+export { useUnwrappedTrigger } from './_composables/useUnwrappedTrigger'
+export type {
+ UseUnwrappedTriggerOptions,
+ UseUnwrappedTriggerReturn,
+} from './_composables/useUnwrappedTrigger'
+export { usePopupContainer, resolvePopupContainer } from './_composables/usePopupContainer'
// utils
export { getGlobalConfig, setGlobalConfig } from './_utils/global-config'
export type { GlobalConfig, PopupContainer } from './_utils/global-config'
-export { getBaseZIndex, nextZIndex, resetZIndex, setBaseZIndex } from './_utils/z-index'
+export {
+ getBaseZIndex,
+ nextZIndex,
+ releaseZIndex,
+ resetZIndex,
+ setBaseZIndex,
+} from './_utils/z-index'
+export type { ZIndexLayer } from './_utils/z-index'
export { derivePalette } from './_utils/color'
+export { computePosition, getViewport, PLACEMENTS, VIEWPORT_MARGIN } from './_utils/position'
+export type {
+ ComputePositionOptions,
+ ComputePositionResult,
+ Placement,
+ PlacementAlign,
+ PlacementBase,
+ RectLike,
+ SizeLike,
+} from './_utils/position'
+export { focusFirst, getFocusable, trapFocus } from './_utils/focus'
+export { getScrollLockCount, lockScroll, unlockScroll } from './_utils/scroll'
+export { bindDrag, bindResize } from './_utils/drag'
+export type { BindDragOptions, BindResizeOptions, DragHandle, ResizeHandle } from './_utils/drag'
// locale
export { enUS, zhCN } from './locale'
@@ -30,7 +110,45 @@ export type { Locale } from './locale'
export interface G3UIOptions extends GlobalConfig {}
-const components = { G3Button, G3ConfigProvider }
+const components = {
+ G3Button,
+ G3ButtonGroup,
+ G3ButtonGroupSeparator,
+ G3CalendarPanel,
+ G3Checkbox,
+ G3Col,
+ G3DatePicker,
+ G3Drawer,
+ G3Dropdown,
+ G3DropdownItem,
+ G3Form,
+ G3FormGroup,
+ G3FormItem,
+ G3Input,
+ G3MessageContainer,
+ G3Modal,
+ G3NotificationContainer,
+ G3Pagination,
+ G3Popover,
+ G3Radio,
+ G3RadioGroup,
+ G3RangePicker,
+ G3Row,
+ G3Scrollbar,
+ G3Segmented,
+ G3Select,
+ G3Space,
+ G3Spin,
+ G3Splitter,
+ G3SplitterPanel,
+ G3Switch,
+ G3TabPane,
+ G3Tabs,
+ G3Tag,
+ G3TimeSelect,
+ G3Tooltip,
+ G3Tree,
+}
function applyConfig(options: G3UIOptions): void {
setGlobalConfig(options)
@@ -41,7 +159,7 @@ function applyConfig(options: G3UIOptions): void {
* 可选的全量注册插件。
* 按需 import 是主路径;这里只是为不熟悉按需引入的团队提供便利入口。
*
- * 函数式调用(Message.xxx() 等组件树外实例)拿不到 inject 上下文,
+ * 函数式调用(G3Message.xxx() 等组件树外实例)拿不到 inject 上下文,
* 用 G3UI.config({ namespace }) 或 app.use(G3UI, { namespace }) 写入全局兜底。
*/
export const G3UI: Plugin & { config: (options?: G3UIOptions) => void } = {
diff --git a/code/g3soft-libs/packages/ui/src/input/Input.test.ts b/code/g3soft-libs/packages/ui/src/input/Input.test.ts
new file mode 100644
index 00000000..0b147841
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/input/Input.test.ts
@@ -0,0 +1,126 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import G3Input from './Input.vue'
+
+function mountInput(props: Record = {}) {
+ return mount(G3Input, { props: { modelValue: '', ...props } })
+}
+
+describe('Input 单行模式', () => {
+ it('输入即同步 v-model', async () => {
+ const wrapper = mountInput()
+ await wrapper.find('input').setValue('你好')
+
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['你好'])
+ })
+
+ it('password 模式可切换明文,切换按钮带 aria 状态', async () => {
+ const wrapper = mountInput({ type: 'password', modelValue: '123456' })
+
+ expect(wrapper.find('input').attributes('type')).toBe('password')
+ const toggle = wrapper.find('.g3-input__password-toggle')
+ expect(toggle.attributes('aria-pressed')).toBe('false')
+
+ await toggle.trigger('click')
+ expect(wrapper.find('input').attributes('type')).toBe('text')
+ expect(wrapper.find('.g3-input__password-toggle').attributes('aria-pressed')).toBe('true')
+ })
+
+ it('number 模式输入时净化(只留一个小数点),失焦时才提交数字', async () => {
+ const wrapper = mountInput({ type: 'number', modelValue: '' })
+ const input = wrapper.find('input')
+
+ await input.setValue('12.3.4')
+ // 净化后的十进制值;setValue 可能同时触发 change(提交为 Number),故统一按字符串比对
+ expect(String(wrapper.emitted('update:modelValue')?.at(-1)?.[0])).toBe('12.34')
+
+ await input.setValue('12.5')
+ await input.trigger('blur')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([12.5])
+ })
+
+ it('number 模式的空值保持空串,不变成 0', async () => {
+ const wrapper = mountInput({ type: 'number', modelValue: 5 })
+ const input = wrapper.find('input')
+
+ await input.setValue('')
+ await input.trigger('blur')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
+ })
+
+ it('status=error 输出 aria-invalid,禁用态使用 g3-input--disabled 修饰类', () => {
+ const wrapper = mountInput({ status: 'error' })
+ expect(wrapper.find('input').attributes('aria-invalid')).toBe('true')
+
+ const disabled = mountInput({ disabled: true })
+ expect(disabled.classes()).toContain('g3-input--disabled')
+ expect(disabled.find('input').attributes('disabled')).toBeDefined()
+ })
+})
+
+describe('Input 清除与计数', () => {
+ it('allowClear:聚焦且有值时出现清除按钮,点击后清空并 emit clear', async () => {
+ const wrapper = mountInput({ allowClear: true, modelValue: 'abc' })
+ expect(wrapper.find('.g3-input__clear').exists()).toBe(false)
+
+ await wrapper.find('input').trigger('focus')
+ const clear = wrapper.find('.g3-input__clear')
+ expect(clear.exists()).toBe(true)
+
+ await clear.trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
+ expect(wrapper.emitted('clear')).toHaveLength(1)
+ })
+
+ it('showCount 显示「当前 / 上限」,无上限时只显示当前', async () => {
+ const withMax = mountInput({ showCount: true, maxlength: 20, modelValue: 'abcd' })
+ expect(withMax.find('.g3-input__count').text()).toBe('4 / 20')
+
+ const noMax = mountInput({ showCount: true, modelValue: 'ab' })
+ expect(noMax.find('.g3-input__count').text()).toBe('2')
+ })
+})
+
+describe('Input 多行模式', () => {
+ it('渲染 textarea 并按 rows 设置行数', () => {
+ const wrapper = mountInput({ type: 'textarea', rows: 5 })
+ const textarea = wrapper.find('textarea')
+
+ expect(textarea.exists()).toBe(true)
+ expect(textarea.attributes('rows')).toBe('5')
+ expect(wrapper.find('input').exists()).toBe(false)
+ })
+
+ it('footer 内提供拖拽手柄(role=slider)与字数统计', () => {
+ const wrapper = mountInput({ type: 'textarea', showCount: true, maxlength: 100, modelValue: 'abc' })
+ const handle = wrapper.find('.g3-input__resize-handle')
+
+ expect(handle.attributes('role')).toBe('slider')
+ expect(handle.attributes('aria-orientation')).toBe('vertical')
+ expect(wrapper.find('.g3-input__count').text()).toBe('3 / 100')
+ })
+
+ it('多行模式不渲染单行的清除按钮与前缀插槽位置', () => {
+ const wrapper = mountInput({ type: 'textarea', allowClear: true, modelValue: 'abc' })
+ expect(wrapper.find('.g3-input__clear').exists()).toBe(false)
+ })
+
+ it('只读 / 禁用时不出现拖拽手柄', () => {
+ expect(mountInput({ type: 'textarea', readonly: true }).find('.g3-input__resize-handle').exists()).toBe(false)
+ expect(mountInput({ type: 'textarea', disabled: true }).find('.g3-input__resize-handle').exists()).toBe(false)
+ })
+})
+
+describe('Input 暴露的方法', () => {
+ it('focus / select / blur 可调用且不抛错', () => {
+ const wrapper = mountInput({ modelValue: 'abc' })
+ const vm = wrapper.vm as unknown as { focus: () => void; select: () => void; blur: () => void }
+
+ expect(() => vm.focus()).not.toThrow()
+ expect(() => vm.select()).not.toThrow()
+ expect(() => vm.blur()).not.toThrow()
+
+ const textarea = mountInput({ type: 'textarea', modelValue: 'abc' })
+ expect(() => (textarea.vm as unknown as { focus: () => void }).focus()).not.toThrow()
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/input/Input.vue b/code/g3soft-libs/packages/ui/src/input/Input.vue
new file mode 100644
index 00000000..f07153af
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/input/Input.vue
@@ -0,0 +1,583 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ countText }}
+
+
+ {{ countText }}
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/input/index.ts b/code/g3soft-libs/packages/ui/src/input/index.ts
new file mode 100644
index 00000000..cdd9a186
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/input/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Input } from './Input.vue'
+export type { InputFocusOptions, InputProps, InputStatus, InputType } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/input/types.ts b/code/g3soft-libs/packages/ui/src/input/types.ts
new file mode 100644
index 00000000..30c13c18
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/input/types.ts
@@ -0,0 +1,38 @@
+/** 输入模式 */
+export type InputType = 'default' | 'password' | 'number' | 'textarea'
+
+/** 校验状态 */
+export type InputStatus = '' | 'error' | 'warning'
+
+export interface InputProps {
+ /** 双向绑定的值 */
+ modelValue?: string | number
+ /** 输入模式 */
+ type?: InputType
+ /** 禁用态 */
+ disabled?: boolean
+ /** 只读态 */
+ readonly?: boolean
+ /** 校验状态 */
+ status?: InputStatus
+ /** 有值时展示清除按钮(hover / 聚焦时出现) */
+ allowClear?: boolean
+ /** 展示字数统计(配合 maxlength 显示 当前 / 上限) */
+ showCount?: boolean
+ /** 最大长度 */
+ maxlength?: string | number
+ /** 多行时的初始行数 */
+ rows?: string | number
+ /** 占位文案 */
+ placeholder?: string
+ /** 浏览器自动填充语义:username / current-password / off 等 */
+ autocomplete?: string
+}
+
+/** 程序化聚焦参数 */
+export interface InputFocusOptions {
+ /** 透传给原生 focus */
+ preventScroll?: boolean
+ /** cursor: 'all' 表示聚焦后全选内容 */
+ cursor?: 'all'
+}
diff --git a/code/g3soft-libs/packages/ui/src/locale/index.ts b/code/g3soft-libs/packages/ui/src/locale/index.ts
index d1cd5809..12c87804 100644
--- a/code/g3soft-libs/packages/ui/src/locale/index.ts
+++ b/code/g3soft-libs/packages/ui/src/locale/index.ts
@@ -2,6 +2,7 @@
* 语言包出口。
*
* 组件不得硬编码中文文案,一律从 locale 取,便于整站切换 / 定制。
+ * 新增文案时同步补 enUS,保持两个语言包结构一致。
*/
export interface Locale {
name: string
@@ -9,15 +10,170 @@ export interface Locale {
empty: {
description: string
}
- [key: string]: unknown
+ /** 跨组件通用文案。 */
+ common: {
+ /** 确认(Modal / 日期面板等) */
+ confirm: string
+ cancel: string
+ clear: string
+ close: string
+ loading: string
+ /** 选择类组件的占位文案 */
+ selectPlaceholder: string
+ /** 输入类组件的占位文案 */
+ inputPlaceholder: string
+ /** 搜索框占位文案 */
+ searchPlaceholder: string
+ /** 无匹配结果 */
+ noMatch: string
+ }
+ /** 分页文案,`{total}` / `{page}` 为占位符。 */
+ pagination: {
+ total: string
+ prev: string
+ next: string
+ page: string
+ pageSize: string
+ goto: string
+ }
+ /** 日期类组件文案,`{year}` / `{month}` 为占位符。 */
+ date: {
+ placeholder: string
+ startPlaceholder: string
+ endPlaceholder: string
+ today: string
+ now: string
+ year: string
+ month: string
+ weekdays: string[]
+ months: string[]
+ }
+ /**
+ * 表单校验文案,`{label}` / `{len}` / `{min}` / `{max}` 为占位符;
+ * label 为空时会连同其后空白一起移除。
+ */
+ form: {
+ required: string
+ type: string
+ pattern: string
+ len: string
+ min: string
+ max: string
+ invalid: string
+ }
}
export const zhCN: Locale = {
name: 'zh-CN',
empty: { description: '暂无数据' },
+ common: {
+ confirm: '确定',
+ cancel: '取消',
+ clear: '清空',
+ close: '关闭',
+ loading: '加载中',
+ selectPlaceholder: '请选择',
+ inputPlaceholder: '请输入',
+ searchPlaceholder: '搜索',
+ noMatch: '无匹配数据',
+ },
+ pagination: {
+ total: '共 {total} 条',
+ prev: '上一页',
+ next: '下一页',
+ page: '页',
+ pageSize: '条/页',
+ goto: '前往',
+ },
+ date: {
+ placeholder: '请选择日期',
+ startPlaceholder: '开始日期',
+ endPlaceholder: '结束日期',
+ today: '今天',
+ now: '此刻',
+ year: '{year} 年',
+ month: '{month} 月',
+ // 与日历矩阵一致:周日开头
+ weekdays: ['日', '一', '二', '三', '四', '五', '六'],
+ months: [
+ '1 月',
+ '2 月',
+ '3 月',
+ '4 月',
+ '5 月',
+ '6 月',
+ '7 月',
+ '8 月',
+ '9 月',
+ '10 月',
+ '11 月',
+ '12 月',
+ ],
+ },
+ form: {
+ required: '{label}不能为空',
+ type: '{label}格式不正确',
+ pattern: '{label}格式不正确',
+ len: '{label}长度必须为 {len}',
+ min: '{label}长度不能少于 {min}',
+ max: '{label}长度不能超过 {max}',
+ invalid: '{label}校验未通过',
+ },
}
export const enUS: Locale = {
name: 'en-US',
empty: { description: 'No data' },
+ common: {
+ confirm: 'OK',
+ cancel: 'Cancel',
+ clear: 'Clear',
+ close: 'Close',
+ loading: 'Loading',
+ selectPlaceholder: 'Please select',
+ inputPlaceholder: 'Please input',
+ searchPlaceholder: 'Search',
+ noMatch: 'No matching data',
+ },
+ pagination: {
+ total: 'Total {total}',
+ prev: 'Previous',
+ next: 'Next',
+ page: 'Page',
+ pageSize: '/ page',
+ goto: 'Go to',
+ },
+ date: {
+ placeholder: 'Select date',
+ startPlaceholder: 'Start date',
+ endPlaceholder: 'End date',
+ today: 'Today',
+ now: 'Now',
+ year: '{year}',
+ month: '{month}',
+ weekdays: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
+ months: [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ],
+ },
+ form: {
+ required: '{label} is required',
+ type: '{label} format is invalid',
+ pattern: '{label} format is invalid',
+ len: '{label} length must be {len}',
+ min: '{label} length must be at least {min}',
+ max: '{label} length must be at most {max}',
+ invalid: '{label} failed validation',
+ },
}
diff --git a/code/g3soft-libs/packages/ui/src/message/Message.vue b/code/g3soft-libs/packages/ui/src/message/Message.vue
new file mode 100644
index 00000000..e750cb1b
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/Message.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/message/MessageContainer.test.ts b/code/g3soft-libs/packages/ui/src/message/MessageContainer.test.ts
new file mode 100644
index 00000000..106694aa
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/MessageContainer.test.ts
@@ -0,0 +1,81 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import { nextTick } from 'vue'
+import G3MessageContainer from './MessageContainer.vue'
+import { messageState } from './store'
+
+/**
+ * 回归重点:方位对齐类曾经手写成 `g3-message-list--xxx`,而样式表里是
+ * `g3-message__list--xxx`(BEM 双下划线),导致 9 个方位的样式全部失效,
+ * 消息一律堆在左上角。这里同时锁住「类名形状」与「默认方位」。
+ */
+afterEach(() => {
+ messageState.items.splice(0, messageState.items.length)
+})
+
+function pushMessage(placement?: string) {
+ messageState.items.push({
+ id: `m-${messageState.items.length + 1}`,
+ type: 'info',
+ content: '提示内容',
+ duration: 3000,
+ closable: false,
+ placement: (placement ?? 'top-center') as never,
+ })
+}
+
+describe('MessageContainer 方位', () => {
+ it('方位类名使用 BEM 双下划线,且能对上 top / center-h', async () => {
+ pushMessage('top-center')
+ const wrapper = mount(G3MessageContainer, { props: { zIndex: 5001 } })
+ await nextTick()
+
+ // 九个方位容器常驻,要取「装着这条消息」的那个
+ const list = wrapper.findAll('.g3-message__list').find((item) => item.text().includes('提示内容'))
+ expect(list).toBeDefined()
+ expect(list!.classes()).toContain('g3-message__list--top')
+ expect(list!.classes()).toContain('g3-message__list--center-h')
+ // 反向锁:不允许再出现 'g3-message-list--*' 这种手写拼法
+ expect(list!.classes().some((name) => name.startsWith('g3-message-list--'))).toBe(false)
+ })
+
+ it('其他方位映射到对应的垂直 / 水平类', async () => {
+ pushMessage('bottom-right')
+ pushMessage('center-left')
+ const wrapper = mount(G3MessageContainer)
+ await nextTick()
+
+ const classNames = wrapper.findAll('.g3-message__list').flatMap((list) => list.classes())
+ expect(classNames).toContain('g3-message__list--bottom')
+ expect(classNames).toContain('g3-message__list--right')
+ expect(classNames).toContain('g3-message__list--center-v')
+ expect(classNames).toContain('g3-message__list--left')
+ })
+
+ it('九个方位容器常驻,保证进出场动画稳定触发', () => {
+ const wrapper = mount(G3MessageContainer)
+ expect(wrapper.findAll('.g3-message__list')).toHaveLength(9)
+ })
+
+ it('容器层级由管理器下发,消息项渲染在对应方位容器内', async () => {
+ pushMessage('top-center')
+ const wrapper = mount(G3MessageContainer, { props: { zIndex: 5001 } })
+ await nextTick()
+
+ expect(wrapper.find('.g3-message__container').attributes('style')).toContain('z-index: 5001')
+ const list = wrapper.findAll('.g3-message__list').find((item) => item.text().includes('提示内容'))
+ expect(list?.classes()).toContain('g3-message__list--top')
+ })
+
+ it('不同方位的消息互不串位', async () => {
+ pushMessage('top-left')
+ pushMessage('bottom-right')
+ const wrapper = mount(G3MessageContainer)
+ await nextTick()
+
+ const lists = wrapper.findAll('.g3-message__list').filter((list) => list.text().includes('提示内容'))
+ expect(lists).toHaveLength(2)
+ expect(lists[0].classes()).toContain('g3-message__list--top')
+ expect(lists[1].classes()).toContain('g3-message__list--bottom')
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/message/MessageContainer.vue b/code/g3soft-libs/packages/ui/src/message/MessageContainer.vue
new file mode 100644
index 00000000..f196a21f
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/MessageContainer.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/message/index.ts b/code/g3soft-libs/packages/ui/src/message/index.ts
new file mode 100644
index 00000000..6c4e8084
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/index.ts
@@ -0,0 +1,12 @@
+export { default as G3MessageItem } from './Message.vue'
+export { default as G3MessageContainer } from './MessageContainer.vue'
+export { default as G3Message } from './manager'
+export { messageState } from './store'
+export type {
+ MessageConfig,
+ MessageHandle,
+ MessageItemState,
+ MessageOptions,
+ MessagePlacement,
+ MessageType,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/message/manager.ts b/code/g3soft-libs/packages/ui/src/message/manager.ts
new file mode 100644
index 00000000..0424eef1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/manager.ts
@@ -0,0 +1,85 @@
+import { createVNode, render } from 'vue'
+import { nextZIndex, releaseZIndex } from '../_utils/z-index'
+import G3MessageContainer from './MessageContainer.vue'
+import { messageState } from './store'
+import type { MessageConfig, MessageHandle, MessageOptions } from './types'
+
+/* ---------- 单例容器 ---------- */
+let containerEl: HTMLElement | null = null
+let containerZIndex = 0
+let seed = 0
+
+function ensureContainer() {
+ if (containerEl || typeof document === 'undefined') return
+ containerEl = document.createElement('div')
+ document.body.appendChild(containerEl)
+ // 反馈类提示层级最高:必须浮于弹窗之上,弹窗内操作的成功 / 失败提示才可见
+ containerZIndex = nextZIndex('feedback')
+ render(createVNode(G3MessageContainer, { zIndex: containerZIndex }), containerEl)
+}
+
+/* ---------- 增删 ---------- */
+function remove(id: string) {
+ const index = messageState.items.findIndex((item) => item.id === id)
+ if (index !== -1) messageState.items.splice(index, 1)
+}
+
+function open(options: MessageOptions): MessageHandle {
+ ensureContainer()
+ const id = `g3-message-${++seed}`
+ // 逐项归一,避免 options 里显式的 undefined 覆盖掉全局默认值
+ messageState.items.push({
+ id,
+ type: options.type ?? 'info',
+ content: options.content ?? '',
+ duration: options.duration ?? messageState.duration,
+ closable: options.closable ?? false,
+ placement: options.placement ?? messageState.placement,
+ })
+ return { close: () => remove(id) }
+}
+
+type ShortcutOptions = Omit
+
+/**
+ * 轻提示 —— 命令式调用,组件树外挂载(自动读取全局配置的命名空间 / 语言包 / 容器)。
+ *
+ * ```ts
+ * G3Message.success('保存成功')
+ * G3Message.error('保存失败', { duration: 0 }) // duration <= 0 不自动关闭
+ * ```
+ */
+export const G3Message = {
+ /** 通用入口 */
+ open,
+ info: (content: string, options?: ShortcutOptions) => open({ ...options, content, type: 'info' }),
+ success: (content: string, options?: ShortcutOptions) =>
+ open({ ...options, content, type: 'success' }),
+ warning: (content: string, options?: ShortcutOptions) =>
+ open({ ...options, content, type: 'warning' }),
+ error: (content: string, options?: ShortcutOptions) =>
+ open({ ...options, content, type: 'error' }),
+ /** 关闭指定消息 */
+ close: (id: string) => remove(id),
+ /** 全局配置:默认方位与默认时长 */
+ config: (config: MessageConfig) => {
+ if (config.placement) messageState.placement = config.placement
+ if (config.duration != null) messageState.duration = config.duration
+ },
+ /** 清空全部消息并卸载容器(切换租户 / 登出时用) */
+ destroy: () => {
+ messageState.items.splice(0, messageState.items.length)
+ if (!containerEl) return
+ render(null, containerEl)
+ containerEl.remove()
+ containerEl = null
+ if (containerZIndex) {
+ releaseZIndex(containerZIndex)
+ containerZIndex = 0
+ }
+ },
+ /** 当前存活的消息条数(调试用) */
+ count: () => messageState.items.length,
+}
+
+export default G3Message
diff --git a/code/g3soft-libs/packages/ui/src/message/store.ts b/code/g3soft-libs/packages/ui/src/message/store.ts
new file mode 100644
index 00000000..b3b8105f
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/store.ts
@@ -0,0 +1,15 @@
+import { reactive } from 'vue'
+import type { MessageItemState, MessagePlacement } from './types'
+
+/** 全局共享的 Message 活跃列表:管理器写入、容器读取,保证响应式联动 */
+export const messageState = reactive<{
+ items: MessageItemState[]
+ /** 默认方位 */
+ placement: MessagePlacement
+ /** 默认自动关闭时长(ms) */
+ duration: number
+}>({
+ items: [],
+ placement: 'top-center',
+ duration: 3000,
+})
diff --git a/code/g3soft-libs/packages/ui/src/message/types.ts b/code/g3soft-libs/packages/ui/src/message/types.ts
new file mode 100644
index 00000000..294dd9af
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/message/types.ts
@@ -0,0 +1,51 @@
+/** 轻提示类型 */
+export type MessageType = 'info' | 'success' | 'warning' | 'error'
+
+/** 轻提示方位 */
+export type MessagePlacement =
+ | 'top-left'
+ | 'top-center'
+ | 'top-right'
+ | 'center-left'
+ | 'center'
+ | 'center-right'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right'
+
+export interface MessageOptions {
+ /** 文案内容 */
+ content: string
+ /** 类型 */
+ type?: MessageType
+ /** 自动关闭时长(ms),<=0 时不自动关闭 */
+ duration?: number
+ /** 是否显示关闭按钮 */
+ closable?: boolean
+ /** 显示方位,默认取全局配置 */
+ placement?: MessagePlacement
+}
+
+/** 容器内单条消息的完整状态 */
+export interface MessageItemState {
+ id: string
+ content: string
+ type: MessageType
+ duration: number
+ closable: boolean
+ placement: MessagePlacement
+}
+
+/** 命令式调用的返回句柄 */
+export interface MessageHandle {
+ /** 关闭这一条 */
+ close: () => void
+}
+
+/** 全局配置项 */
+export interface MessageConfig {
+ /** 默认方位 */
+ placement?: MessagePlacement
+ /** 默认自动关闭时长 */
+ duration?: number
+}
diff --git a/code/g3soft-libs/packages/ui/src/modal/Modal.vue b/code/g3soft-libs/packages/ui/src/modal/Modal.vue
new file mode 100644
index 00000000..809e4047
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/modal/Modal.vue
@@ -0,0 +1,539 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ resolvedCancelText }}
+
+
+ {{ resolvedConfirmText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/modal/ModalConfirm.vue b/code/g3soft-libs/packages/ui/src/modal/ModalConfirm.vue
new file mode 100644
index 00000000..90f41bc2
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/modal/ModalConfirm.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+ {{ content }}
+
+
+ {{ cancelText ?? locale.common.cancel }}
+
+
+ {{ confirmText ?? locale.common.confirm }}
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/modal/confirm.ts b/code/g3soft-libs/packages/ui/src/modal/confirm.ts
new file mode 100644
index 00000000..178b008a
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/modal/confirm.ts
@@ -0,0 +1,39 @@
+import { createVNode, render } from 'vue'
+import G3ModalConfirm from './ModalConfirm.vue'
+import type { ModalConfirmHandle, ModalConfirmOptions } from './types'
+
+/**
+ * 命令式确认对话框:自建容器挂到 body,关闭动画结束(Modal 的 closed 事件)
+ * 后才卸载容器,保证退场动画完整播放。
+ *
+ * 用法:
+ * ```ts
+ * G3Modal.confirm({
+ * title: '确定删除?',
+ * content: '此操作不可恢复',
+ * confirmText: '删除',
+ * confirmButtonProps: { danger: true },
+ * onConfirm: async () => { ... }, // 返回 Promise 时按钮进入 loading;reject 保持打开
+ * onCancel: () => {},
+ * })
+ * ```
+ */
+export function confirm(options: ModalConfirmOptions = {}): ModalConfirmHandle {
+ const container = document.createElement('div')
+ const host = typeof document === 'undefined' ? null : document.body
+ host?.appendChild(container)
+
+ let destroyed = false
+ function destroy() {
+ if (destroyed) return
+ destroyed = true
+ render(null, container)
+ container.remove()
+ }
+
+ render(createVNode(G3ModalConfirm, { ...options, onClosed: destroy }), container)
+
+ return { destroy }
+}
+
+export default confirm
diff --git a/code/g3soft-libs/packages/ui/src/modal/index.ts b/code/g3soft-libs/packages/ui/src/modal/index.ts
new file mode 100644
index 00000000..f37e3738
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/modal/index.ts
@@ -0,0 +1,8 @@
+import G3ModalComponent from './Modal.vue'
+import { confirm } from './confirm'
+
+/** 对话框组件(带命令式 `confirm` 方法) */
+export const G3Modal = Object.assign(G3ModalComponent, { confirm })
+
+export { confirm as modalConfirm }
+export type { ModalConfirmHandle, ModalConfirmOptions, ModalProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/modal/types.ts b/code/g3soft-libs/packages/ui/src/modal/types.ts
new file mode 100644
index 00000000..1644ee59
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/modal/types.ts
@@ -0,0 +1,61 @@
+import type { ButtonProps } from '../button/types'
+import type { PopupContainer } from '../_utils/global-config'
+
+export interface ModalProps {
+ /** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
+ open?: boolean
+ /** 非受控时的初始打开状态 */
+ defaultOpen?: boolean
+ /** 标题文字 */
+ title?: string
+ /** 面板宽度(px 数字或任意 CSS 宽度字符串) */
+ width?: number | string
+ /** 垂直居中显示 */
+ centered?: boolean
+ /** 是否显示遮罩 */
+ mask?: boolean
+ /** 点击遮罩关闭 */
+ maskClosable?: boolean
+ /** Esc 关闭 */
+ keyboard?: boolean
+ /** 显示右上角关闭按钮 */
+ closable?: boolean
+ /** 显示右上角全屏切换按钮 */
+ fullscreen?: boolean
+ /** 允许拖动右下角手柄调整宽高 */
+ resizable?: boolean
+ /** 显示底部按钮区(确定 / 取消);配合 #footer 插槽自定义 */
+ footer?: boolean
+ /** 确认按钮文字 */
+ confirmText?: string
+ /** 取消按钮文字 */
+ cancelText?: string
+ /** 确认中:按钮 loading 且阻止一切关闭操作 */
+ confirmLoading?: boolean
+ /** 弹层挂载容器;不传时取 ConfigProvider.popupContainer,再退回 body */
+ popupContainer?: PopupContainer
+}
+
+/** 命令式确认对话框的参数 */
+export interface ModalConfirmOptions {
+ /** 标题 */
+ title?: string
+ /** 正文(纯文本);也可用 content 插槽之外的富内容由 #content 提供 */
+ content?: string
+ /** 确认按钮文字 */
+ confirmText?: string
+ /** 取消按钮文字 */
+ cancelText?: string
+ /** 透传给确认按钮;`danger: true` 等价于 `type: 'danger'` */
+ confirmButtonProps?: Partial & { danger?: boolean }
+ /** 确认回调;返回 Promise 时按钮进入 loading(resolve 关闭、reject 保持打开) */
+ onConfirm?: () => void | Promise
+ /** 取消回调;只有点「取消」按钮才会触发(遮罩 / Esc / 关闭按钮为静默关闭) */
+ onCancel?: () => void
+}
+
+/** 命令式确认对话框的句柄 */
+export interface ModalConfirmHandle {
+ /** 立即关闭并卸载(不等退场动画) */
+ destroy: () => void
+}
diff --git a/code/g3soft-libs/packages/ui/src/notification/Notification.vue b/code/g3soft-libs/packages/ui/src/notification/Notification.vue
new file mode 100644
index 00000000..3b2bdd83
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/Notification.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/notification/NotificationContainer.vue b/code/g3soft-libs/packages/ui/src/notification/NotificationContainer.vue
new file mode 100644
index 00000000..5a58b6e2
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/NotificationContainer.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/notification/index.ts b/code/g3soft-libs/packages/ui/src/notification/index.ts
new file mode 100644
index 00000000..797e68c6
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/index.ts
@@ -0,0 +1,12 @@
+export { default as G3NotificationItem } from './Notification.vue'
+export { default as G3NotificationContainer } from './NotificationContainer.vue'
+export { default as G3Notification } from './manager'
+export { notificationState } from './store'
+export type {
+ NotificationConfig,
+ NotificationHandle,
+ NotificationItemState,
+ NotificationOptions,
+ NotificationPlacement,
+ NotificationType,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/notification/manager.ts b/code/g3soft-libs/packages/ui/src/notification/manager.ts
new file mode 100644
index 00000000..13791fb5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/manager.ts
@@ -0,0 +1,85 @@
+import { createVNode, render } from 'vue'
+import { nextZIndex, releaseZIndex } from '../_utils/z-index'
+import G3NotificationContainer from './NotificationContainer.vue'
+import { notificationState } from './store'
+import type {
+ NotificationConfig,
+ NotificationHandle,
+ NotificationOptions,
+} from './types'
+
+/* ---------- 单例容器 ---------- */
+let containerEl: HTMLElement | null = null
+let containerZIndex = 0
+let seed = 0
+
+function ensureContainer() {
+ if (containerEl || typeof document === 'undefined') return
+ containerEl = document.createElement('div')
+ document.body.appendChild(containerEl)
+ // 与轻提示同档(feedback):必须浮于弹窗之上
+ containerZIndex = nextZIndex('feedback')
+ render(createVNode(G3NotificationContainer, { zIndex: containerZIndex }), containerEl)
+}
+
+function remove(id: string) {
+ const index = notificationState.items.findIndex((item) => item.id === id)
+ if (index !== -1) notificationState.items.splice(index, 1)
+}
+
+function open(options: NotificationOptions): NotificationHandle {
+ ensureContainer()
+ const id = `g3-notification-${++seed}`
+ // 逐项归一,避免 options 里显式的 undefined 覆盖掉全局默认值
+ notificationState.items.push({
+ id,
+ type: options.type ?? 'info',
+ title: options.title ?? '',
+ description: options.description ?? '',
+ duration: options.duration ?? notificationState.duration,
+ closable: options.closable ?? true,
+ placement: options.placement ?? notificationState.placement,
+ })
+ return { close: () => remove(id) }
+}
+
+type ShortcutOptions = Omit
+
+/**
+ * 通知提醒 —— 命令式调用,组件树外挂载(自动读取全局配置的命名空间 / 语言包 / 容器)。
+ *
+ * ```ts
+ * G3Notification.success({ title: '发布成功', description: '版本 1.2.0 已上线' })
+ * ```
+ */
+export const G3Notification = {
+ /** 通用入口 */
+ open,
+ info: (options: ShortcutOptions) => open({ ...options, type: 'info' }),
+ success: (options: ShortcutOptions) => open({ ...options, type: 'success' }),
+ warning: (options: ShortcutOptions) => open({ ...options, type: 'warning' }),
+ error: (options: ShortcutOptions) => open({ ...options, type: 'error' }),
+ /** 关闭指定通知 */
+ close: (id: string) => remove(id),
+ /** 全局配置:默认方位与默认时长 */
+ config: (config: NotificationConfig) => {
+ if (config.placement) notificationState.placement = config.placement
+ if (config.duration != null) notificationState.duration = config.duration
+ },
+ /** 清空全部通知并卸载容器 */
+ destroy: () => {
+ notificationState.items.splice(0, notificationState.items.length)
+ if (!containerEl) return
+ render(null, containerEl)
+ containerEl.remove()
+ containerEl = null
+ if (containerZIndex) {
+ releaseZIndex(containerZIndex)
+ containerZIndex = 0
+ }
+ },
+ /** 当前存活的通知条数(调试用) */
+ count: () => notificationState.items.length,
+}
+
+export default G3Notification
diff --git a/code/g3soft-libs/packages/ui/src/notification/store.ts b/code/g3soft-libs/packages/ui/src/notification/store.ts
new file mode 100644
index 00000000..4b2871c5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/store.ts
@@ -0,0 +1,15 @@
+import { reactive } from 'vue'
+import type { NotificationItemState, NotificationPlacement } from './types'
+
+/** 全局共享的 Notification 活跃列表:管理器写入、容器读取,保证响应式联动 */
+export const notificationState = reactive<{
+ items: NotificationItemState[]
+ /** 默认方位 */
+ placement: NotificationPlacement
+ /** 默认自动关闭时长(ms) */
+ duration: number
+}>({
+ items: [],
+ placement: 'top-right',
+ duration: 4500,
+})
diff --git a/code/g3soft-libs/packages/ui/src/notification/types.ts b/code/g3soft-libs/packages/ui/src/notification/types.ts
new file mode 100644
index 00000000..c80f2bc4
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/notification/types.ts
@@ -0,0 +1,47 @@
+import type { MessageType } from '../message/types'
+
+/** 通知类型 */
+export type NotificationType = MessageType
+
+/** 通知方位(通知只出现在四角) */
+export type NotificationPlacement = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
+
+export interface NotificationOptions {
+ /** 标题 */
+ title?: string
+ /** 描述内容 */
+ description?: string
+ /** 类型 */
+ type?: NotificationType
+ /** 自动关闭时长(ms),<=0 时不自动关闭 */
+ duration?: number
+ /** 是否显示关闭按钮 */
+ closable?: boolean
+ /** 显示方位,默认取全局配置 */
+ placement?: NotificationPlacement
+}
+
+/** 容器内单条通知的完整状态 */
+export interface NotificationItemState {
+ id: string
+ title: string
+ description: string
+ type: NotificationType
+ duration: number
+ closable: boolean
+ placement: NotificationPlacement
+}
+
+/** 命令式调用的返回句柄 */
+export interface NotificationHandle {
+ /** 关闭这一条 */
+ close: () => void
+}
+
+/** 全局配置项 */
+export interface NotificationConfig {
+ /** 默认方位 */
+ placement?: NotificationPlacement
+ /** 默认自动关闭时长 */
+ duration?: number
+}
diff --git a/code/g3soft-libs/packages/ui/src/pagination/Pagination.vue b/code/g3soft-libs/packages/ui/src/pagination/Pagination.vue
new file mode 100644
index 00000000..82de3f38
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/pagination/Pagination.vue
@@ -0,0 +1,364 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/pagination/index.ts b/code/g3soft-libs/packages/ui/src/pagination/index.ts
new file mode 100644
index 00000000..08fa9bdb
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/pagination/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Pagination } from './Pagination.vue'
+export type { PaginationProps, PaginationTotalScope } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/pagination/types.ts b/code/g3soft-libs/packages/ui/src/pagination/types.ts
new file mode 100644
index 00000000..f2f9ab37
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/pagination/types.ts
@@ -0,0 +1,29 @@
+export interface PaginationProps {
+ /** 当前页(v-model:current);不传则内部维护,默认 1 */
+ current?: number
+ /** 每页条数(v-model:pageSize);不传则内部维护,默认 10 */
+ pageSize?: number
+ /** 总条数 */
+ total?: number
+ /** 整组禁用 */
+ disabled?: boolean
+ /** 仅 1 页时隐藏整个分页器 */
+ hideOnSinglePage?: boolean
+ /** 显示每页条数选择器 */
+ showSizeChanger?: boolean
+ /** 每页条数选项 */
+ pageSizeOptions?: Array
+ /** 显示快速跳转输入框 */
+ showQuickJumper?: boolean
+ /** 显示「共 X 条」 */
+ showTotal?: boolean
+}
+
+/** #showTotal 插槽作用域 */
+export interface PaginationTotalScope {
+ total: number
+ /** 当前页第一条的序号(total 为 0 时是 0) */
+ from: number
+ /** 当前页最后一条的序号 */
+ to: number
+}
diff --git a/code/g3soft-libs/packages/ui/src/popover/Popover.vue b/code/g3soft-libs/packages/ui/src/popover/Popover.vue
new file mode 100644
index 00000000..45257dd6
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/popover/Popover.vue
@@ -0,0 +1,401 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/popover/index.ts b/code/g3soft-libs/packages/ui/src/popover/index.ts
new file mode 100644
index 00000000..da18f3e7
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/popover/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Popover } from './Popover.vue'
+export type { PopoverProps, PopoverTrigger } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/popover/types.ts b/code/g3soft-libs/packages/ui/src/popover/types.ts
new file mode 100644
index 00000000..b8b9055e
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/popover/types.ts
@@ -0,0 +1,34 @@
+import type { PopupContainer } from '../_utils/global-config'
+import type { Placement } from '../_utils/position'
+
+/** 触发方式 */
+export type PopoverTrigger = 'click' | 'hover' | 'focus' | 'contextmenu' | 'manual'
+
+export interface PopoverProps {
+ /** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
+ open?: boolean
+ /** 非受控时的初始打开状态 */
+ defaultOpen?: boolean
+ /** 触发方式 */
+ trigger?: PopoverTrigger
+ /** 期望方位 */
+ placement?: Placement
+ /** 与触发器的间距(px) */
+ offset?: number
+ /** 是否显示箭头 */
+ arrow?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+ /** hover 触发打开延迟(ms) */
+ mouseEnterDelay?: number
+ /** hover 触发关闭延迟(ms) */
+ mouseLeaveDelay?: number
+ /** 透传到浮层内容盒子的额外 class,便于派生组件定制内容样式 */
+ contentClass?: string
+ /** 浮层宽度是否以触发器宽度为下限(Select / DatePicker 等需要对齐触发框) */
+ followTriggerWidth?: boolean
+ /** 零包裹模式:把事件直接注入触发器单根 vnode,不再包一层 span */
+ unwrapped?: boolean
+ /** 弹层挂载容器;不传时取 ConfigProvider.popupContainer,再退回 body */
+ popupContainer?: PopupContainer
+}
diff --git a/code/g3soft-libs/packages/ui/src/radio/Radio.vue b/code/g3soft-libs/packages/ui/src/radio/Radio.vue
new file mode 100644
index 00000000..9d961ff2
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/radio/Radio.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/radio/RadioGroup.vue b/code/g3soft-libs/packages/ui/src/radio/RadioGroup.vue
new file mode 100644
index 00000000..6640a823
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/radio/RadioGroup.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/radio/context.ts b/code/g3soft-libs/packages/ui/src/radio/context.ts
new file mode 100644
index 00000000..fa83bd70
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/radio/context.ts
@@ -0,0 +1,18 @@
+import type { ComputedRef, InjectionKey } from 'vue'
+
+/** 单选项的值 */
+export type RadioValue = string | number | boolean
+
+/** RadioGroup 通过 provide 下发的组上下文 */
+export interface RadioGroupContext {
+ /** 组的原生 name(同组互斥 + 方向键导航) */
+ name: ComputedRef
+ /** 当前选中值 */
+ modelValue: ComputedRef
+ /** 整组是否禁用 */
+ disabled: ComputedRef
+ /** 选中某项 */
+ select: (value: RadioValue) => void
+}
+
+export const radioGroupKey: InjectionKey = Symbol('g3-radio-group')
diff --git a/code/g3soft-libs/packages/ui/src/radio/index.ts b/code/g3soft-libs/packages/ui/src/radio/index.ts
new file mode 100644
index 00000000..c26f1fe7
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/radio/index.ts
@@ -0,0 +1,5 @@
+export { default as G3Radio } from './Radio.vue'
+export { default as G3RadioGroup } from './RadioGroup.vue'
+export type { RadioGroupContext, RadioValue } from './context'
+export { radioGroupKey } from './context'
+export type { RadioGroupProps, RadioProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/radio/types.ts b/code/g3soft-libs/packages/ui/src/radio/types.ts
new file mode 100644
index 00000000..f26063ed
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/radio/types.ts
@@ -0,0 +1,17 @@
+import type { RadioValue } from './context'
+
+export interface RadioProps {
+ /** 选项值,与 RadioGroup 的 modelValue 比较确定是否选中 */
+ value: RadioValue
+ /** 禁用态 */
+ disabled?: boolean
+}
+
+export interface RadioGroupProps {
+ /** 双向绑定的选中值 */
+ modelValue?: RadioValue
+ /** 禁用整组 */
+ disabled?: boolean
+ /** 组名,未设置时自动生成,避免多组之间互斥冲突 */
+ name?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/scrollbar/Scrollbar.vue b/code/g3soft-libs/packages/ui/src/scrollbar/Scrollbar.vue
new file mode 100644
index 00000000..b2766028
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/scrollbar/Scrollbar.vue
@@ -0,0 +1,329 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/scrollbar/index.ts b/code/g3soft-libs/packages/ui/src/scrollbar/index.ts
new file mode 100644
index 00000000..a818297a
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/scrollbar/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Scrollbar } from './Scrollbar.vue'
+export type { ScrollbarAutoHide, ScrollbarProps, ScrollbarVisibility } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/scrollbar/types.ts b/code/g3soft-libs/packages/ui/src/scrollbar/types.ts
new file mode 100644
index 00000000..e4c3cf99
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/scrollbar/types.ts
@@ -0,0 +1,16 @@
+/** 滚动条显示策略:visible 常显 / auto 按 autoHide 规则显隐 / hidden 隐藏 */
+export type ScrollbarVisibility = 'visible' | 'auto' | 'hidden'
+
+/** 自动隐藏策略:never 永不 / scroll 滚动时显示 / move 鼠标移动或滚动时显示 / leave 鼠标移出后隐藏 */
+export type ScrollbarAutoHide = 'never' | 'scroll' | 'move' | 'leave'
+
+export interface ScrollbarProps {
+ /** 滚动条显示策略 */
+ visibility?: ScrollbarVisibility
+ /** 自动隐藏策略 */
+ autoHide?: ScrollbarAutoHide
+ /** 点击轨道时是否把滑块跳过去(false 时轨道只作视觉提示) */
+ clickScroll?: boolean
+ /** 无障碍标签 */
+ ariaLabel?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/segmented/Segmented.vue b/code/g3soft-libs/packages/ui/src/segmented/Segmented.vue
new file mode 100644
index 00000000..17fc3bf9
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/segmented/Segmented.vue
@@ -0,0 +1,330 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/segmented/index.ts b/code/g3soft-libs/packages/ui/src/segmented/index.ts
new file mode 100644
index 00000000..63bb8f27
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/segmented/index.ts
@@ -0,0 +1,8 @@
+export { default as G3Segmented } from './Segmented.vue'
+export type {
+ SegmentedOption,
+ SegmentedOptionInput,
+ SegmentedProps,
+ SegmentedShape,
+ SegmentedValue,
+} from './types'
diff --git a/code/g3soft-libs/packages/ui/src/segmented/types.ts b/code/g3soft-libs/packages/ui/src/segmented/types.ts
new file mode 100644
index 00000000..109c8ffa
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/segmented/types.ts
@@ -0,0 +1,39 @@
+import type { Component, VNode } from 'vue'
+
+/** 分段项的值 */
+export type SegmentedValue = string | number | boolean
+
+/** 分段项(对象形态) */
+export interface SegmentedOption {
+ /** 显示文字;不传即为纯图标项 */
+ label?: string
+ /** 选项值 */
+ value: SegmentedValue
+ /** 是否禁用该项 */
+ disabled?: boolean
+ /** 图标:组件或 VNode */
+ icon?: Component | VNode
+}
+
+/** 分段项:原始值或对象 */
+export type SegmentedOptionInput = SegmentedValue | SegmentedOption
+
+/** 圆角形状 */
+export type SegmentedShape = 'default' | 'round'
+
+export interface SegmentedProps {
+ /** 双向绑定的选中值 */
+ modelValue?: SegmentedValue
+ /** 选项数据源,与 #label 插槽配合使用 */
+ options?: SegmentedOptionInput[]
+ /** 圆角形状:default 跟随控件圆角,round 全圆角 */
+ shape?: SegmentedShape
+ /** 纵向排列,滑块上下移动 */
+ vertical?: boolean
+ /** 撑满父容器宽度(纵向时为高度),各项等分空间 */
+ block?: boolean
+ /** 禁用整组 */
+ disabled?: boolean
+ /** 原生 radio 组名,未设置时自动生成,避免多组之间互斥冲突 */
+ name?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/select/Select.vue b/code/g3soft-libs/packages/ui/src/select/Select.vue
new file mode 100644
index 00000000..2cfa7fa1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/select/Select.vue
@@ -0,0 +1,788 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
{{ notFound }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/select/index.ts b/code/g3soft-libs/packages/ui/src/select/index.ts
new file mode 100644
index 00000000..dcd5acd1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/select/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Select } from './Select.vue'
+export type { SelectProps, SelectValue } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/select/types.ts b/code/g3soft-libs/packages/ui/src/select/types.ts
new file mode 100644
index 00000000..5fdb04a3
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/select/types.ts
@@ -0,0 +1,31 @@
+/** 选择器的值:单选为单值,多选为数组 */
+export type SelectValue = string | number | boolean | object | null | undefined
+
+export interface SelectProps {
+ /** 双向绑定的选中值;多选时为数组 */
+ modelValue?: SelectValue | SelectValue[]
+ /** 选项数组;每项默认取 { label, value, disabled },可用 labelKey / valueKey 自定义字段名 */
+ options?: Array | string | number>
+ /** 禁用态 */
+ disabled?: boolean
+ /** 有值时展示清除按钮 */
+ clearable?: boolean
+ /** 多选模式:modelValue 为数组,选中后下拉保持打开 */
+ multiple?: boolean
+ /** 是否开启筛选(默认开启;开启后直接在触发器输入框输入即可筛选) */
+ filterable?: boolean
+ /** 自定义筛选函数:(input, option) => boolean;不传则用默认的 label / value 包含匹配 */
+ filterOption?: (input: string, option: unknown) => boolean
+ /** 远程搜索模式:输入时触发 search 事件,选项由父层更新,不再执行本地筛选 */
+ remote?: boolean
+ /** 远程数据加载状态;开启时右侧仅展示加载图标 */
+ loading?: boolean
+ /** 无匹配项时的文案;不传则取语言包 */
+ notFoundText?: string
+ /** 是否允许自由输入:true 时可输入任意文本并作为值 */
+ allowFreeInput?: boolean
+ /** 选项对象中作为显示文本的字段名 */
+ labelKey?: string
+ /** 选项对象中作为值的字段名 */
+ valueKey?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/space/Space.vue b/code/g3soft-libs/packages/ui/src/space/Space.vue
new file mode 100644
index 00000000..1c481fb2
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/space/Space.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/space/index.ts b/code/g3soft-libs/packages/ui/src/space/index.ts
new file mode 100644
index 00000000..44b8c299
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/space/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Space } from './Space.vue'
+export type { SpaceAlign, SpaceOrientation, SpaceProps, SpaceSize } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/space/types.ts b/code/g3soft-libs/packages/ui/src/space/types.ts
new file mode 100644
index 00000000..fd1245ae
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/space/types.ts
@@ -0,0 +1,21 @@
+/** 间距预设档位 */
+export type SpaceSize = 'small' | 'middle' | 'large' | number | string
+
+/** 间距方向 */
+export type SpaceOrientation = 'horizontal' | 'vertical'
+
+/** 交叉轴对齐 */
+export type SpaceAlign = 'start' | 'end' | 'center' | 'baseline'
+
+export interface SpaceProps {
+ /** 间距:预设名 small / middle / large、数字(px)、[水平, 垂直] */
+ size?: SpaceSize | [SpaceSize, SpaceSize]
+ /** 排列方向;不传时由 vertical 决定 */
+ orientation?: SpaceOrientation
+ /** 垂直排列简写;与 orientation 同时配置时以 orientation 优先 */
+ vertical?: boolean
+ /** 交叉轴对齐:水平默认 center,垂直默认不设置(stretch) */
+ align?: SpaceAlign
+ /** 是否自动换行,仅水平方向生效 */
+ wrap?: boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/spin/Spin.vue b/code/g3soft-libs/packages/ui/src/spin/Spin.vue
new file mode 100644
index 00000000..fde8febd
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/spin/Spin.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/spin/index.ts b/code/g3soft-libs/packages/ui/src/spin/index.ts
new file mode 100644
index 00000000..dde038e7
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/spin/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Spin } from './Spin.vue'
+export type { SpinProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/spin/types.ts b/code/g3soft-libs/packages/ui/src/spin/types.ts
new file mode 100644
index 00000000..6c6d3bb1
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/spin/types.ts
@@ -0,0 +1,8 @@
+export interface SpinProps {
+ /** 是否加载中 */
+ spinning?: boolean
+ /** 延迟显示(毫秒),防短请求闪烁 */
+ delay?: number
+ /** 加载文案 */
+ description?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/splitter/Panel.vue b/code/g3soft-libs/packages/ui/src/splitter/Panel.vue
new file mode 100644
index 00000000..b66d3710
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/splitter/Panel.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/splitter/Splitter.vue b/code/g3soft-libs/packages/ui/src/splitter/Splitter.vue
new file mode 100644
index 00000000..aa0710d5
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/splitter/Splitter.vue
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/splitter/index.ts b/code/g3soft-libs/packages/ui/src/splitter/index.ts
new file mode 100644
index 00000000..1eab50fb
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/splitter/index.ts
@@ -0,0 +1,3 @@
+export { default as G3Splitter } from './Splitter.vue'
+export { default as G3SplitterPanel } from './Panel.vue'
+export type { SplitterDirection, SplitterPanelProps, SplitterProps, SplitterSize } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/splitter/types.ts b/code/g3soft-libs/packages/ui/src/splitter/types.ts
new file mode 100644
index 00000000..ead73cbd
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/splitter/types.ts
@@ -0,0 +1,25 @@
+/** 分割方向:horizontal 左右分栏 / vertical 上下分栏 */
+export type SplitterDirection = 'horizontal' | 'vertical'
+
+/** 尺寸:数字(px)或 CSS 长度(如 '30%'、'240px') */
+export type SplitterSize = number | string
+
+export interface SplitterProps {
+ /** 分割方向 */
+ direction?: SplitterDirection
+ /** 分割线仅在 hover / 聚焦 / 拖拽时显示 */
+ barOnHover?: boolean
+}
+
+export interface SplitterPanelProps {
+ /** 最小尺寸,拖拽约束 */
+ min?: SplitterSize
+ /** 最大尺寸,拖拽约束 */
+ max?: SplitterSize
+ /** 受控尺寸(由 Splitter 注入或父级传入);传入后拖拽不再内部接管显示 */
+ size?: SplitterSize
+ /** 非受控初始尺寸;拖拽后由内部接管 */
+ defaultSize?: SplitterSize
+ /** 是否允许被相邻分隔条调整(两侧都为 true 才可拖) */
+ resizable?: boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/switch/Switch.vue b/code/g3soft-libs/packages/ui/src/switch/Switch.vue
new file mode 100644
index 00000000..628c7429
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/switch/Switch.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/switch/index.ts b/code/g3soft-libs/packages/ui/src/switch/index.ts
new file mode 100644
index 00000000..07706dac
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/switch/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Switch } from './Switch.vue'
+export type { SwitchProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/switch/types.ts b/code/g3soft-libs/packages/ui/src/switch/types.ts
new file mode 100644
index 00000000..d656a713
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/switch/types.ts
@@ -0,0 +1,6 @@
+export interface SwitchProps {
+ /** 双向绑定的开关状态 */
+ modelValue?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/tabs/TabPane.vue b/code/g3soft-libs/packages/ui/src/tabs/TabPane.vue
new file mode 100644
index 00000000..5d7ee603
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tabs/TabPane.vue
@@ -0,0 +1,17 @@
+
diff --git a/code/g3soft-libs/packages/ui/src/tabs/Tabs.test.ts b/code/g3soft-libs/packages/ui/src/tabs/Tabs.test.ts
new file mode 100644
index 00000000..6ffdd661
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tabs/Tabs.test.ts
@@ -0,0 +1,114 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import G3TabPane from './TabPane.vue'
+import G3Tabs from './Tabs.vue'
+
+/**
+ * 回归重点:面板列表曾经把 `v-if` 与 `v-for` 写在同一元素上,
+ * Vue 3 里 `v-if` 先求值,`pane` 还是 undefined,直接抛
+ * 「Cannot read properties of undefined (reading 'key')」导致整个 Tabs 渲染不出来。
+ */
+function mountTabs(options: { modelValue?: string; disabledTab?: boolean } = {}) {
+ return mount({
+ components: { G3Tabs, G3TabPane },
+ data() {
+ return { active: options.modelValue ?? 'a' }
+ },
+ template: `
+
+ 内容一
+ 内容二
+ 内容三
+
+ `,
+ })
+}
+
+describe('Tabs 渲染', () => {
+ it('挂载含多个面板时不抛错,标签按声明顺序渲染', () => {
+ const wrapper = mountTabs()
+ const tabs = wrapper.findAll('.g3-tabs__tab')
+
+ expect(tabs).toHaveLength(3)
+ expect(tabs.map((tab) => tab.text())).toEqual(['标签一', '标签二', '禁用项'])
+ })
+
+ it('面板懒渲染:只挂载当前激活项,切换后旧面板保留(不丢表单状态)', async () => {
+ const wrapper = mountTabs()
+ expect(wrapper.findAll('.g3-tabs__pane')).toHaveLength(1)
+ expect(wrapper.find('.pane-a').exists()).toBe(true)
+ expect(wrapper.find('.pane-b').exists()).toBe(false)
+
+ const tabs = wrapper.findAll('.g3-tabs__tab')
+ await tabs[1].trigger('click')
+
+ expect(wrapper.findAll('.g3-tabs__pane')).toHaveLength(2)
+ expect(wrapper.find('.pane-b').exists()).toBe(true)
+ // 切走的面板用 v-show 隐藏而不是卸载(happy-dom 的 isVisible 不可靠,直接看内联样式)
+ const panes = wrapper.findAll('.g3-tabs__pane')
+ const hidden = panes.find((pane) => pane.attributes('style')?.includes('display: none'))
+ expect(hidden?.find('.pane-a').exists()).toBe(true)
+ })
+
+ it('无障碍属性:role / aria-selected / aria-controls 对应面板 id', async () => {
+ const wrapper = mountTabs()
+ const tabs = wrapper.findAll('.g3-tabs__tab')
+ const list = wrapper.find(`.${'g3-tabs__nav-list'}`)
+
+ expect(list.attributes('role')).toBe('tablist')
+ expect(tabs[0].attributes('role')).toBe('tab')
+ expect(tabs[0].attributes('aria-selected')).toBe('true')
+ expect(tabs[1].attributes('aria-selected')).toBe('false')
+
+ const firstPanelId = tabs[0].attributes('aria-controls')
+ const panel = wrapper.find('.g3-tabs__pane')
+ expect(panel.attributes('role')).toBe('tabpanel')
+ expect(panel.attributes('id')).toBe(firstPanelId)
+ expect(panel.attributes('aria-labelledby')).toBe(tabs[0].attributes('id'))
+ })
+
+ it('点击标签切换激活项', async () => {
+ const wrapper = mountTabs()
+ await wrapper.findAll('.g3-tabs__tab')[1].trigger('click')
+ expect(wrapper.find('.pane-b').isVisible()).toBe(true)
+ })
+
+ it('禁用项点击无效', async () => {
+ const wrapper = mountTabs({ disabledTab: true })
+ const disabled = wrapper.findAll('.g3-tabs__tab')[2]
+
+ expect(disabled.attributes('disabled')).toBeDefined()
+ expect(disabled.attributes('aria-disabled')).toBe('true')
+ await disabled.trigger('click')
+ expect(wrapper.find('.pane-c').exists()).toBe(false)
+ })
+
+ it('键盘左右键在可用标签间循环', async () => {
+ const wrapper = mountTabs()
+ const list = wrapper.find('.g3-tabs__nav-list')
+
+ await list.trigger('keydown', { key: 'ArrowRight' })
+ expect(wrapper.find('.pane-b').isVisible()).toBe(true)
+
+ await list.trigger('keydown', { key: 'ArrowRight' })
+ await list.trigger('keydown', { key: 'ArrowRight' })
+ // 只有两个可用项,循环回到第一个
+ expect(wrapper.find('.pane-a').isVisible()).toBe(true)
+ })
+})
+
+describe('Tabs items 数据源', () => {
+ it('提供 items 时优先于声明式子组件', async () => {
+ const wrapper = mount(G3Tabs, {
+ props: {
+ modelValue: 'x',
+ items: [
+ { key: 'x', label: 'X 标签' },
+ { key: 'y', label: 'Y 标签' },
+ ],
+ },
+ })
+
+ expect(wrapper.findAll('.g3-tabs__tab').map((tab) => tab.text())).toEqual(['X 标签', 'Y 标签'])
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/tabs/Tabs.vue b/code/g3soft-libs/packages/ui/src/tabs/Tabs.vue
new file mode 100644
index 00000000..9bcbb3ee
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tabs/Tabs.vue
@@ -0,0 +1,464 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/tabs/index.ts b/code/g3soft-libs/packages/ui/src/tabs/index.ts
new file mode 100644
index 00000000..23c81a84
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tabs/index.ts
@@ -0,0 +1,3 @@
+export { default as G3Tabs } from './Tabs.vue'
+export { default as G3TabPane } from './TabPane.vue'
+export type { TabItem, TabKey, TabPaneProps, TabsPlacement, TabsProps, TabsType } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/tabs/types.ts b/code/g3soft-libs/packages/ui/src/tabs/types.ts
new file mode 100644
index 00000000..6a934a4d
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tabs/types.ts
@@ -0,0 +1,48 @@
+import type { VNode } from 'vue'
+
+/** 标签栏位置 */
+export type TabsPlacement = 'top' | 'left' | 'right'
+
+/** 视觉风格:line 下划线指示条 / pill 胶囊 */
+export type TabsType = 'line' | 'pill'
+
+/** 标签项的 key */
+export type TabKey = string | number
+
+/** items 数据源的一项 */
+export interface TabItem {
+ /** 唯一标识 */
+ key: TabKey
+ /** 标签文本;也可传 VNode */
+ label?: string | VNode
+ /** 禁用该项 */
+ disabled?: boolean
+ /** 面板内容:VNode 或返回 VNode 的渲染函数 */
+ content?: VNode | (() => VNode)
+}
+
+export interface TabPaneProps {
+ /** 唯一标识;不传时按声明顺序取索引 */
+ tabKey?: TabKey
+ /** 标签文本(有 #label 插槽时忽略) */
+ label?: string
+ /** 禁用态 */
+ disabled?: boolean
+}
+
+export interface TabsProps {
+ /** 双向绑定的激活面板 key */
+ modelValue?: TabKey
+ /** 标签栏位置 */
+ placement?: TabsPlacement
+ /** 视觉风格 */
+ type?: TabsType
+ /** 只渲染标签栏,内容由外部控制 */
+ navOnly?: boolean
+ /** 标签栏居中 */
+ centered?: boolean
+ /** 整组禁用 */
+ disabled?: boolean
+ /** 数据源;提供且非空时优先于 G3TabPane 插槽 */
+ items?: TabItem[]
+}
diff --git a/code/g3soft-libs/packages/ui/src/tag/Tag.vue b/code/g3soft-libs/packages/ui/src/tag/Tag.vue
new file mode 100644
index 00000000..b2b4c4a3
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tag/Tag.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/tag/index.ts b/code/g3soft-libs/packages/ui/src/tag/index.ts
new file mode 100644
index 00000000..b459b06c
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tag/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Tag } from './Tag.vue'
+export type { TagProps, TagVariant } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/tag/types.ts b/code/g3soft-libs/packages/ui/src/tag/types.ts
new file mode 100644
index 00000000..f476d116
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tag/types.ts
@@ -0,0 +1,13 @@
+/** 标签视觉变体 */
+export type TagVariant = 'default' | 'secondary' | 'outline' | 'success' | 'danger' | 'ghost'
+
+export interface TagProps {
+ /** 视觉变体 */
+ variant?: TagVariant
+ /** 是否可关闭:显示关闭按钮,点击后触发 close 并隐藏自身 */
+ closable?: boolean
+ /** 禁用态 */
+ disabled?: boolean
+ /** 链接地址:传入后渲染为 ,可点击跳转 */
+ href?: string
+}
diff --git a/code/g3soft-libs/packages/ui/src/tooltip/Tooltip.vue b/code/g3soft-libs/packages/ui/src/tooltip/Tooltip.vue
new file mode 100644
index 00000000..6a7d8baf
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tooltip/Tooltip.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.content }}
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/tooltip/index.ts b/code/g3soft-libs/packages/ui/src/tooltip/index.ts
new file mode 100644
index 00000000..74387e70
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tooltip/index.ts
@@ -0,0 +1,2 @@
+export { default as G3Tooltip } from './Tooltip.vue'
+export type { TooltipProps } from './types'
diff --git a/code/g3soft-libs/packages/ui/src/tooltip/types.ts b/code/g3soft-libs/packages/ui/src/tooltip/types.ts
new file mode 100644
index 00000000..b8722fad
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tooltip/types.ts
@@ -0,0 +1,20 @@
+import type { Placement } from '../_utils/position'
+
+export interface TooltipProps {
+ /** 提示文本(纯文本);与 #content 插槽同时存在时插槽优先 */
+ content?: string
+ /** 受控打开状态;不传则非受控 */
+ open?: boolean
+ /** 非受控时的初始打开状态 */
+ defaultOpen?: boolean
+ /** 期望方位 */
+ placement?: Placement
+ /** 是否显示箭头 */
+ arrow?: boolean
+ /** hover 触发打开延迟(ms) */
+ mouseEnterDelay?: number
+ /** hover 触发关闭延迟(ms) */
+ mouseLeaveDelay?: number
+ /** 禁用态 */
+ disabled?: boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/tree/Tree.vue b/code/g3soft-libs/packages/ui/src/tree/Tree.vue
new file mode 100644
index 00000000..0044ea19
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/Tree.vue
@@ -0,0 +1,812 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ locale.empty.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue b/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue
new file mode 100644
index 00000000..14f9ee85
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/TreeNode.vue
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ part.text }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/g3soft-libs/packages/ui/src/tree/context.ts b/code/g3soft-libs/packages/ui/src/tree/context.ts
new file mode 100644
index 00000000..83c0cf52
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/context.ts
@@ -0,0 +1,5 @@
+import type { InjectionKey } from 'vue'
+import type { TreeContext } from './types'
+
+/** Tree 通过 provide 下发的节点上下文,由 G3TreeNode 消费 */
+export const treeContextKey: InjectionKey = Symbol('g3-tree')
diff --git a/code/g3soft-libs/packages/ui/src/tree/index.ts b/code/g3soft-libs/packages/ui/src/tree/index.ts
new file mode 100644
index 00000000..bc8a2e21
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/index.ts
@@ -0,0 +1,24 @@
+export { default as G3Tree } from './Tree.vue'
+export { default as G3TreeNode } from './TreeNode.vue'
+export { treeContextKey } from './context'
+export type {
+ DropPosition,
+ TreeCheckPayload,
+ TreeContext,
+ TreeContextMenuItem,
+ TreeDropInfo,
+ TreeFieldNames,
+ TreeKey,
+ TreeNodeData,
+ TreeProps,
+} from './types'
+export {
+ buildFlat,
+ calcDropPosition,
+ collectDescendants,
+ conductCheck,
+ filterTree,
+ getParentKeys,
+ isLeafNode,
+ resolveFieldNames,
+} from './utils'
diff --git a/code/g3soft-libs/packages/ui/src/tree/types.ts b/code/g3soft-libs/packages/ui/src/tree/types.ts
new file mode 100644
index 00000000..514603ca
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/types.ts
@@ -0,0 +1,156 @@
+import type { Component, VNode } from 'vue'
+
+/** 树节点标识 */
+export type TreeKey = string | number
+
+/** 落点位置:节点前 / 节点内 / 节点后 */
+export type DropPosition = 'before' | 'inside' | 'after'
+
+/** 字段名映射 */
+export interface TreeFieldNames {
+ /** 唯一标识字段,默认 key */
+ key?: string
+ /** 标题字段,默认 title */
+ title?: string
+ /** 子节点字段,默认 children */
+ children?: string
+ /** 图标字段,默认 icon */
+ icon?: string
+}
+
+/** 节点数据 */
+export interface TreeNodeData {
+ /** 唯一标识 */
+ key?: TreeKey
+ /** 标题 */
+ title?: string
+ /** 子节点 */
+ children?: TreeNodeData[]
+ /** 节点图标(组件或 VNode) */
+ icon?: Component | VNode
+ /** 是否禁用该节点 */
+ disabled?: boolean
+ /** 是否禁用该节点的复选框 */
+ disableCheckbox?: boolean
+ /** 是否允许拖动该节点 */
+ draggable?: boolean
+ /** 是否叶子节点(懒加载模式下用于判断是否还要请求子节点) */
+ isLeaf?: boolean
+ [field: string]: unknown
+}
+
+/** 右键菜单项 */
+export interface TreeContextMenuItem {
+ key: string | number
+ label?: string
+ icon?: Component | VNode
+ disabled?: boolean
+ danger?: boolean
+ divider?: boolean
+}
+
+/** 落点信息 */
+export interface TreeDropInfo {
+ dragKey: TreeKey
+ dropKey: TreeKey
+ dropPosition: DropPosition
+ node: TreeNodeData
+ event: DragEvent
+}
+
+/** 勾选事件的载荷 */
+export interface TreeCheckPayload {
+ checked: TreeKey[]
+ halfChecked: TreeKey[]
+}
+
+export interface TreeProps {
+ /** 树数据(嵌套结构) */
+ treeData?: TreeNodeData[]
+ /** 字段名映射 */
+ fieldNames?: TreeFieldNames
+ /** 显示复选框 */
+ checkable?: boolean
+ /** 允许选中(false 时行点击不选中) */
+ selectable?: boolean
+ /** 多选:Ctrl / Cmd + 点击切换,普通点击单选替换 */
+ multiple?: boolean
+ /** 勾选严格模式,父子不联动 */
+ checkStrictly?: boolean
+ /** 整树禁用 */
+ disabled?: boolean
+ /** 异步加载模式;首次展开未加载节点时 emit load */
+ lazy?: boolean
+ /** 允许拖拽排序 */
+ draggable?: boolean
+ /** 把树外拖入源映射成拖拽键,返回 null 表示不识别 */
+ externalDragKey?: (dataTransfer: DataTransfer, event: DragEvent) => TreeKey | null
+ /** 过滤关键词(大小写不敏感匹配标题),命中节点与其祖先可见并高亮 */
+ filter?: string
+ /** 节点占满整行宽度 */
+ blockNode?: boolean
+ /** 初始展开全部(仅非受控初始化时生效) */
+ defaultExpandAll?: boolean
+ /** 初始补全 defaultExpandedKeys 的祖先路径 */
+ defaultExpandParent?: boolean
+ /** 选中 / 勾选后自动展开其祖先路径 */
+ autoExpandParent?: boolean
+ /** 右键菜单数据源:数组,或 (node, key) => items(node 为 null 表示空白处) */
+ contextMenu?: TreeContextMenuItem[] | ((node: TreeNodeData | null, key: TreeKey | null) => TreeContextMenuItem[])
+ /** 受控展开键(undefined 即非受控) */
+ expandedKeys?: TreeKey[]
+ /** 受控选中键 */
+ selectedKeys?: TreeKey[]
+ /** 受控勾选键;也接受 { checked: [] } 形态 */
+ checkedKeys?: TreeKey[] | { checked: TreeKey[] }
+ /** 受控已加载键(懒加载用) */
+ loadedKeys?: TreeKey[]
+ /** 非受控初始展开键 */
+ defaultExpandedKeys?: TreeKey[]
+ /** 非受控初始选中键 */
+ defaultSelectedKeys?: TreeKey[]
+ /** 非受控初始勾选键 */
+ defaultCheckedKeys?: TreeKey[]
+ /** 非受控初始已加载键 */
+ defaultLoadedKeys?: TreeKey[]
+}
+
+/** 节点级上下文(Tree 通过 provide 下发,TreeNode 消费) */
+export interface TreeContext {
+ fieldNames: Required
+ checkable: boolean
+ selectable: boolean
+ multiple: boolean
+ checkStrictly: boolean
+ disabled: boolean
+ lazy: boolean
+ draggable: boolean
+ blockNode: boolean
+ expandedSet: Set
+ selectedSet: Set
+ /** 需要渲染缩进占位的叶子节点:同一批兄弟里存在可展开节点时才留位对齐 */
+ placeholderKeys: Set
+ checkedSet: Set
+ halfSet: Set
+ loadedSet: Set
+ loadingSet: Set
+ filterActive: boolean
+ filterVisible: Set | null
+ filterKeyword: string
+ activeKey: string | null
+ dragKey: string | null
+ dropKey: string | null
+ dropPosition: DropPosition | null
+ toggleExpand: (node: TreeNodeData, key: TreeKey) => void
+ handleSelect: (node: TreeNodeData, key: TreeKey, event: MouseEvent) => void
+ handleCheck: (node: TreeNodeData, key: TreeKey) => void
+ onDragStart: (node: TreeNodeData, key: TreeKey, event: DragEvent) => void
+ onDragOver: (node: TreeNodeData, key: TreeKey, event: DragEvent) => void
+ onDragLeave: (node: TreeNodeData, key: TreeKey, event: DragEvent) => void
+ onDrop: (node: TreeNodeData, key: TreeKey, event: DragEvent) => void
+ onDragEnd: (node: TreeNodeData, key: TreeKey, event: DragEvent) => void
+ onNodeContextMenu: (node: TreeNodeData, key: TreeKey, event: MouseEvent) => void
+ registerNode: (key: string, el: HTMLElement | null) => void
+ unregisterNode: (key: string) => void
+ isActive: (key: string) => boolean
+}
diff --git a/code/g3soft-libs/packages/ui/src/tree/utils.test.ts b/code/g3soft-libs/packages/ui/src/tree/utils.test.ts
new file mode 100644
index 00000000..7d65bd69
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/utils.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from 'vitest'
+import type { TreeNodeData } from './types'
+import {
+ buildFlat,
+ calcDropPosition,
+ collectDescendants,
+ conductCheck,
+ filterTree,
+ getParentKeys,
+ isLeafNode,
+ resolveFieldNames,
+} from './utils'
+
+const FIELD_NAMES = resolveFieldNames()
+
+const TREE: TreeNodeData[] = [
+ {
+ key: '1',
+ title: '前端',
+ children: [
+ { key: '1-1', title: 'Vue', children: [{ key: '1-1-1', title: '组合式 API' }] },
+ { key: '1-2', title: 'React' },
+ ],
+ },
+ { key: '2', title: '后端', children: [{ key: '2-1', title: 'Node.js' }] },
+]
+
+describe('resolveFieldNames', () => {
+ it('默认 key / title / children / icon,可局部覆盖', () => {
+ expect(FIELD_NAMES).toEqual({ key: 'key', title: 'title', children: 'children', icon: 'icon' })
+ expect(resolveFieldNames({ title: 'name' })).toEqual({
+ key: 'key',
+ title: 'name',
+ children: 'children',
+ icon: 'icon',
+ })
+ })
+})
+
+describe('buildFlat', () => {
+ const flat = buildFlat(TREE, FIELD_NAMES)
+
+ it('层级表按 DFS 深度记录', () => {
+ expect(flat.levelMap.get('1')).toBe(0)
+ expect(flat.levelMap.get('1-1')).toBe(1)
+ expect(flat.levelMap.get('1-1-1')).toBe(2)
+ expect(flat.levelMap.get('2-1')).toBe(1)
+ })
+
+ it('父键表与根键列表', () => {
+ expect(flat.parentMap.get('1')).toBeNull()
+ expect(flat.parentMap.get('1-1-1')).toBe('1-1')
+ expect(flat.rootKeys).toEqual(['1', '2'])
+ })
+
+ it('子键表与节点表', () => {
+ expect(flat.childrenMap.get('1')).toEqual(['1-1', '1-2'])
+ expect(flat.childrenMap.get('1-2')).toEqual([])
+ expect(flat.nodeMap.get('2-1')?.title).toBe('Node.js')
+ })
+})
+
+describe('collectDescendants / getParentKeys', () => {
+ const flat = buildFlat(TREE, FIELD_NAMES)
+
+ it('含自身在内的全部子孙', () => {
+ const keys = new Set(collectDescendants('1', flat.childrenMap))
+ expect(keys).toEqual(new Set(['1', '1-1', '1-1-1', '1-2']))
+ expect(collectDescendants('2-1', flat.childrenMap)).toEqual(['2-1'])
+ })
+
+ it('回溯祖先且不含自身、不重复', () => {
+ expect(getParentKeys(['1-1-1'], flat.parentMap)).toEqual(['1-1', '1'])
+ expect(getParentKeys(['1-1-1', '1-2'], flat.parentMap)).toEqual(['1-1', '1'])
+ expect(getParentKeys(['1'], flat.parentMap)).toEqual([])
+ })
+})
+
+describe('conductCheck(父子勾选联动)', () => {
+ it('勾选父节点:子孙全部选中,且不产生半选', () => {
+ const { checked, half } = conductCheck(TREE, ['1'], FIELD_NAMES)
+ expect(checked).toEqual(new Set(['1', '1-1', '1-1-1', '1-2']))
+ expect(half.size).toBe(0)
+ })
+
+ it('只勾选部分子节点:父节点变半选,且半选不出现在选中集合里', () => {
+ const { checked, half } = conductCheck(TREE, ['1-1'], FIELD_NAMES)
+ expect(checked).toEqual(new Set(['1-1', '1-1-1']))
+ expect(half).toEqual(new Set(['1']))
+ half.forEach((key) => expect(checked.has(key)).toBe(false))
+ })
+
+ it('子节点全部勾选后父节点转为选中', () => {
+ const { checked, half } = conductCheck(TREE, ['1-1', '1-2'], FIELD_NAMES)
+ expect(checked).toEqual(new Set(['1', '1-1', '1-1-1', '1-2']))
+ expect(half.size).toBe(0)
+ })
+
+ it('父节点只有一个子节点时,勾选该子节点会让父节点转为选中而非半选', () => {
+ const { checked, half } = conductCheck(TREE, ['2-1'], FIELD_NAMES)
+ expect(checked).toEqual(new Set(['2-1', '2']))
+ expect(half.size).toBe(0)
+ })
+
+ it('跨子树勾选互不影响', () => {
+ const { checked, half } = conductCheck(TREE, ['1-1', '2-1'], FIELD_NAMES)
+ expect(checked).toEqual(new Set(['1-1', '1-1-1', '2-1', '2']))
+ expect(half).toEqual(new Set(['1']))
+ })
+
+ it('空集合时无选中无半选', () => {
+ const { checked, half } = conductCheck(TREE, [], FIELD_NAMES)
+ expect(checked.size).toBe(0)
+ expect(half.size).toBe(0)
+ })
+})
+
+describe('filterTree(过滤:命中节点 ∪ 祖先)', () => {
+ it('命中叶子时把祖先链一起带出,并标记需要展开的祖先', () => {
+ const { matched, expand } = filterTree(TREE, 'vue', FIELD_NAMES)
+ expect(matched).toEqual(new Set(['1-1', '1']))
+ expect(expand).toEqual(new Set(['1']))
+ })
+
+ it('大小写不敏感、忽略首尾空白', () => {
+ expect(filterTree(TREE, ' NODE ', FIELD_NAMES).matched).toEqual(new Set(['2-1', '2']))
+ })
+
+ it('关键词为空时返回空集合(不隐藏任何节点)', () => {
+ const { matched, expand } = filterTree(TREE, ' ', FIELD_NAMES)
+ expect(matched.size).toBe(0)
+ expect(expand.size).toBe(0)
+ })
+
+ it('命中父节点时其自身可见', () => {
+ const { matched } = filterTree(TREE, '前端', FIELD_NAMES)
+ expect(matched).toEqual(new Set(['1']))
+ })
+})
+
+describe('calcDropPosition(落点:上 25% 前 / 下 25% 后 / 中间放入)', () => {
+ const rect = { top: 100, height: 40 } as DOMRect
+
+ it('上四分之一判前', () => {
+ expect(calcDropPosition(105, rect)).toBe('before')
+ })
+
+ it('中间判放入内部', () => {
+ expect(calcDropPosition(120, rect)).toBe('inside')
+ })
+
+ it('下四分之一判后', () => {
+ expect(calcDropPosition(140, rect)).toBe('after')
+ })
+})
+
+describe('isLeafNode', () => {
+ it('显式 isLeaf 优先级最高', () => {
+ expect(isLeafNode({ isLeaf: true, children: [{}] }, FIELD_NAMES, { lazy: false, loadedSet: new Set() })).toBe(true)
+ expect(isLeafNode({ isLeaf: false }, FIELD_NAMES, { lazy: false, loadedSet: new Set() })).toBe(false)
+ })
+
+ it('有子节点即非叶子;无子节点即叶子', () => {
+ expect(isLeafNode(TREE[0], FIELD_NAMES, { lazy: false, loadedSet: new Set() })).toBe(false)
+ expect(isLeafNode({ key: 'x' }, FIELD_NAMES, { lazy: false, loadedSet: new Set() })).toBe(true)
+ })
+
+ it('懒加载下「未加载过」的无子节点仍可展开', () => {
+ const node = { key: 'lazy-1' }
+ expect(isLeafNode(node, FIELD_NAMES, { lazy: true, loadedSet: new Set() })).toBe(false)
+ expect(isLeafNode(node, FIELD_NAMES, { lazy: true, loadedSet: new Set(['lazy-1']) })).toBe(true)
+ })
+})
diff --git a/code/g3soft-libs/packages/ui/src/tree/utils.ts b/code/g3soft-libs/packages/ui/src/tree/utils.ts
new file mode 100644
index 00000000..4102ca8d
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/src/tree/utils.ts
@@ -0,0 +1,216 @@
+import type { DropPosition, TreeFieldNames, TreeKey, TreeNodeData } from './types'
+
+/**
+ * 树数据纯函数集合:字段解析、扁平索引、勾选联动、过滤、落点判定。
+ * 全部无副作用,组件层只负责把结果接进响应式状态。
+ */
+
+export const DEFAULT_FIELD_NAMES: Required = {
+ key: 'key',
+ title: 'title',
+ children: 'children',
+ icon: 'icon',
+}
+
+/** 合并字段名映射 */
+export function resolveFieldNames(fieldNames?: TreeFieldNames): Required {
+ return { ...DEFAULT_FIELD_NAMES, ...(fieldNames ?? {}) }
+}
+
+export function getNodeKey(node: TreeNodeData, fieldNames: Required): TreeKey {
+ return node[fieldNames.key] as TreeKey
+}
+
+export function getNodeTitle(node: TreeNodeData, fieldNames: Required): string {
+ const title = node[fieldNames.title]
+ return title == null ? '' : String(title)
+}
+
+/** 非空子节点数组才返回,否则 null(「有无子节点」的唯一判定口径) */
+export function getNodeChildren(
+ node: TreeNodeData,
+ fieldNames: Required,
+): TreeNodeData[] | null {
+ const children = node[fieldNames.children]
+ return Array.isArray(children) && children.length > 0 ? (children as TreeNodeData[]) : null
+}
+
+/** 是否叶子节点:显式 isLeaf 优先,否则按子节点 / 懒加载状态判断 */
+export function isLeafNode(
+ node: TreeNodeData,
+ fieldNames: Required,
+ options: { lazy: boolean; loadedSet: Set },
+): boolean {
+ if (node.isLeaf === true) return true
+ if (node.isLeaf === false) return false
+ const children = getNodeChildren(node, fieldNames)
+ if (children) return false
+ if (options.lazy) return options.loadedSet.has(String(getNodeKey(node, fieldNames)))
+ return true
+}
+
+export interface FlatTreeInfo {
+ parentMap: Map
+ levelMap: Map
+ nodeMap: Map
+ childrenMap: Map
+ rootKeys: string[]
+}
+
+/** 递归 DFS 一次建好 5 张索引表(父键 / 层级 / 节点 / 子键 / 根键) */
+export function buildFlat(treeData: TreeNodeData[], fieldNames: Required): FlatTreeInfo {
+ const parentMap = new Map()
+ const levelMap = new Map()
+ const nodeMap = new Map()
+ const childrenMap = new Map()
+ const rootKeys: string[] = []
+
+ function walk(nodes: TreeNodeData[], parentKey: string | null, level: number) {
+ for (const node of nodes) {
+ const key = String(getNodeKey(node, fieldNames))
+ parentMap.set(key, parentKey)
+ levelMap.set(key, level)
+ nodeMap.set(key, node)
+ const children = getNodeChildren(node, fieldNames)
+ const childKeys = children ? children.map((child) => String(getNodeKey(child, fieldNames))) : []
+ childrenMap.set(key, childKeys)
+ if (parentKey === null) rootKeys.push(key)
+ if (children) walk(children, key, level + 1)
+ }
+ }
+
+ walk(treeData, null, 0)
+ return { parentMap, levelMap, nodeMap, childrenMap, rootKeys }
+}
+
+/** 含自身在内的全部子孙 */
+export function collectDescendants(rootKey: string, childrenMap: Map): string[] {
+ const result: string[] = []
+ const stack = [rootKey]
+ while (stack.length) {
+ const key = stack.pop() as string
+ result.push(key)
+ const children = childrenMap.get(key)
+ if (children) stack.push(...children)
+ }
+ return result
+}
+
+/** 回溯所有祖先(去重,不含自身) */
+export function getParentKeys(targetKeys: TreeKey[], parentMap: Map): string[] {
+ const result = new Set()
+ for (const target of targetKeys) {
+ let current = parentMap.get(String(target))
+ while (current) {
+ if (result.has(current)) break
+ result.add(current)
+ current = parentMap.get(current)
+ }
+ }
+ return [...result]
+}
+
+/**
+ * 勾选联动:显式勾选节点的子孙全部勾选,其余节点自底向上回推——
+ * 子节点全选中则父节点选中,部分选中则父节点半选。
+ */
+export function conductCheck(
+ treeData: TreeNodeData[],
+ explicitChecked: Iterable,
+ fieldNames: Required,
+): { checked: Set; half: Set } {
+ const checked = new Set()
+ const half = new Set()
+ const explicit = new Set([...explicitChecked].map(String))
+
+ function walk(node: TreeNodeData, ancestors: string[]): { all: boolean; some: boolean } {
+ const key = String(getNodeKey(node, fieldNames))
+ const children = getNodeChildren(node, fieldNames)
+ if (explicit.has(key)) {
+ checked.add(key)
+ // 父选中 ⇒ 子孙全选中
+ if (children) {
+ const stack = [...children]
+ while (stack.length) {
+ const child = stack.pop() as TreeNodeData
+ checked.add(String(getNodeKey(child, fieldNames)))
+ const grandChildren = getNodeChildren(child, fieldNames)
+ if (grandChildren) stack.push(...grandChildren)
+ }
+ }
+ return { all: true, some: true }
+ }
+
+ if (!children) return { all: false, some: false }
+
+ let allChildrenChecked = true
+ let someChildChecked = false
+ for (const child of children) {
+ const result = walk(child, [...ancestors, key])
+ if (result.all) someChildChecked = true
+ else {
+ allChildrenChecked = false
+ if (result.some) someChildChecked = true
+ }
+ }
+
+ if (allChildrenChecked && children.length > 0) {
+ checked.add(key)
+ return { all: true, some: true }
+ }
+ if (someChildChecked) {
+ half.add(key)
+ return { all: false, some: true }
+ }
+ return { all: false, some: false }
+ }
+
+ for (const node of treeData) walk(node, [])
+ // 半选集合里不能出现在选中集合中
+ half.forEach((key) => checked.delete(key))
+ return { checked, half }
+}
+
+/** 过滤:命中节点 ∪ 其祖先可见,命中的祖先链需要自动展开 */
+export function filterTree(
+ treeData: TreeNodeData[],
+ keyword: string,
+ fieldNames: Required,
+): { matched: Set; expand: Set } {
+ const matched = new Set()
+ const expand = new Set()
+ const text = keyword.trim().toLowerCase()
+ if (!text) return { matched, expand }
+
+ function walk(node: TreeNodeData, ancestors: string[]): boolean {
+ const key = String(getNodeKey(node, fieldNames))
+ const title = getNodeTitle(node, fieldNames).toLowerCase()
+ const hit = title.includes(text)
+ if (hit) {
+ matched.add(key)
+ ancestors.forEach((ancestor) => {
+ matched.add(ancestor)
+ expand.add(ancestor)
+ })
+ }
+ const children = getNodeChildren(node, fieldNames)
+ let childHit = false
+ if (children) {
+ for (const child of children) {
+ if (walk(child, [...ancestors, key])) childHit = true
+ }
+ }
+ return hit || childHit
+ }
+
+ for (const node of treeData) walk(node, [])
+ return { matched, expand }
+}
+
+/** 落点判定:行的上 25% 前、下 25% 后、中间「放入内部」 */
+export function calcDropPosition(clientY: number, rect: DOMRect): DropPosition {
+ 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/g3soft-libs/packages/ui/tests/setup.ts b/code/g3soft-libs/packages/ui/tests/setup.ts
new file mode 100644
index 00000000..1e04ee92
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/tests/setup.ts
@@ -0,0 +1,41 @@
+/**
+ * 单测环境补丁 —— happy-dom 缺少组件依赖的若干浏览器 API。
+ *
+ * happy-dom 没有真实布局引擎,所以:
+ * - 依赖实测尺寸的逻辑(浮层定位、Splitter 拖拽)拿不到有效值,那类行为放到 E2E(Playwright)里验;
+ * - 这里只补「不补就会抛错」的 API,让组件能正常挂载与交互。
+ */
+
+class ResizeObserverStub {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+if (!('ResizeObserver' in globalThis)) {
+ ;(globalThis as unknown as Record).ResizeObserver = ResizeObserverStub
+}
+
+if (typeof window !== 'undefined') {
+ if (!window.matchMedia) {
+ window.matchMedia = ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ })) as unknown as typeof window.matchMedia
+ }
+
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {}
+ }
+
+ // happy-dom 未实现 PointerEvent 时用 MouseEvent 兜底,保证指针交互的监听能注册
+ if (!('PointerEvent' in window)) {
+ ;(window as unknown as Record).PointerEvent = MouseEvent
+ }
+}
diff --git a/code/g3soft-libs/packages/ui/vite.config.ts b/code/g3soft-libs/packages/ui/vite.config.ts
index 6941b908..d62436ee 100644
--- a/code/g3soft-libs/packages/ui/vite.config.ts
+++ b/code/g3soft-libs/packages/ui/vite.config.ts
@@ -60,7 +60,8 @@ export default defineConfig({
fileName: () => 'index.js',
},
rollupOptions: {
- external: ['vue'],
+ // 图标库同理外置:由消费方(或本包的 dependencies)解析,避免重复打包
+ external: ['vue', '@lucide/vue'],
output: {
preserveModules: true,
preserveModulesRoot: 'src',
diff --git a/code/g3soft-libs/packages/ui/vitest.config.ts b/code/g3soft-libs/packages/ui/vitest.config.ts
new file mode 100644
index 00000000..f2be3146
--- /dev/null
+++ b/code/g3soft-libs/packages/ui/vitest.config.ts
@@ -0,0 +1,51 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vitest/config'
+
+/**
+ * 单测(L1 纯函数 + L2 组件)配置。
+ *
+ * 刻意**不复用** vite.config.ts:那份配置带 lib 构建与 dts 插件,与测试无关;
+ * 这里只保留组件编译必需的 vue 插件、SCSS 与命名空间常量(与构建同一事实源)。
+ *
+ * 输出:默认 reporter 给人看,json reporter 写到 test-results/unit.json 给工具/AI 读。
+ */
+
+const configScssPath = fileURLToPath(new URL('../tokens/src/scss/_config.scss', import.meta.url))
+const configScss = readFileSync(configScssPath, 'utf-8')
+
+function readScssDefault(name: string, fallback: string): string {
+ const match = configScss.match(
+ new RegExp(`\\$${name}\\s*:\\s*['"]([^'"]+)['"]\\s*!default`),
+ )
+ return match ? match[1] : fallback
+}
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: [
+ // 测试里大量用字符串模板挂载组件,需要带编译器的完整版 Vue
+ { find: /^vue$/, replacement: 'vue/dist/vue.esm-bundler.js' },
+ ],
+ },
+ define: {
+ __G3_NAMESPACE__: JSON.stringify(readScssDefault('namespace', 'g3')),
+ __G3_STATE_PREFIX__: JSON.stringify(readScssDefault('state-prefix', 'is')),
+ },
+ css: {
+ preprocessorOptions: {
+ scss: { api: 'modern-compiler' },
+ },
+ },
+ test: {
+ environment: 'happy-dom',
+ setupFiles: [fileURLToPath(new URL('./tests/setup.ts', import.meta.url))],
+ include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
+ reporters: ['default', 'json'],
+ outputFile: { json: 'test-results/unit.json' },
+ // 组件测试会 Teleport 到 body 并触发 ResizeObserver / rAF,统一在 setup 里补齐
+ restoreMocks: true,
+ },
+})
diff --git a/code/g3soft-libs/pnpm-lock.yaml b/code/g3soft-libs/pnpm-lock.yaml
index 6716fefc..6c91f0be 100644
--- a/code/g3soft-libs/pnpm-lock.yaml
+++ b/code/g3soft-libs/pnpm-lock.yaml
@@ -26,9 +26,6 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
- vue-docgen-api:
- specifier: ^4.79.0
- version: 4.79.2(vue@3.5.43(typescript@5.9.3))
docs:
dependencies:
@@ -43,10 +40,10 @@ importers:
version: 12.11.1
docus:
specifier: ^5.14.0
- version: 5.14.0(db3a3babb3e1a5f38f7fe64c400fed97)
+ version: 5.14.0(f92feb3e0732c978b98957e5210d7bcc)
nuxt:
specifier: 4.4.8
- version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
vue:
specifier: 'catalog:'
version: 3.5.43(typescript@5.9.3)
@@ -55,6 +52,31 @@ importers:
specifier: 'catalog:'
version: 1.105.1
+ packages/framework:
+ dependencies:
+ '@g3soft/ui':
+ specifier: workspace:*
+ version: link:../ui
+ devDependencies:
+ '@vitejs/plugin-vue':
+ specifier: ^6.0.0
+ version: 6.0.9(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
+ publint:
+ specifier: latest
+ version: 0.3.25
+ vite:
+ specifier: ^8.0.0
+ version: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
+ vite-plugin-dts:
+ specifier: latest
+ version: 5.1.2(@vue/language-core@3.3.12)(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ vue:
+ specifier: 'catalog:'
+ version: 3.5.43(typescript@5.9.3)
+ vue-tsc:
+ specifier: ^3.0.0
+ version: 3.3.12(typescript@5.9.3)
+
packages/tokens:
devDependencies:
sass:
@@ -66,10 +88,22 @@ importers:
'@g3soft/tokens':
specifier: workspace:*
version: link:../tokens
+ '@lucide/vue':
+ specifier: ^1.31.0
+ version: 1.52.0(vue@3.5.43(typescript@5.9.3))
devDependencies:
+ '@playwright/test':
+ specifier: ^1.63.0
+ version: 1.63.0
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.9(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
+ '@vue/test-utils':
+ specifier: ^2.5.1
+ version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))
+ happy-dom:
+ specifier: ^20.14.5
+ version: 20.14.5
publint:
specifier: latest
version: 0.3.25
@@ -82,6 +116,9 @@ importers:
vite-plugin-dts:
specifier: latest
version: 5.1.2(@vue/language-core@3.3.12)(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ vitest:
+ specifier: ^5.0.3
+ version: 5.0.3(@types/node@26.6.4)(happy-dom@20.14.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
vue:
specifier: 'catalog:'
version: 3.5.43(typescript@5.9.3)
@@ -1263,6 +1300,11 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
+ '@lucide/vue@1.52.0':
+ resolution: {integrity: sha512-Gwr6c14jhY6BscoFU+B9OKsr7lg8RTAKOLgW0e61kkFwkt9Q7CZmB9AyJcvhoqapEJgRg2bFoeAcO+cqpgKzEQ==}
+ peerDependencies:
+ vue: '>=3.0.1'
+
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@@ -1585,6 +1627,9 @@ packages:
zod:
optional: true
+ '@one-ini/wasm@0.2.1':
+ resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==}
+
'@oxc-minify/binding-android-arm-eabi@0.133.0':
resolution: {integrity: sha512-D8M1+nqwLaACHZsld/t6f+cE4N97XOu5iQ88f1ZaYH4ptFzFrXo5N7wUKACTI4xmNUD+6W0Y4Apk5U2r8HLdBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2576,6 +2621,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.63.0':
+ resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -3407,9 +3457,15 @@ packages:
'@tybys/wasm-util@0.10.4':
resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -3459,6 +3515,12 @@ packages:
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
+
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
+
'@typescript-eslint/project-service@8.71.1':
resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3562,6 +3624,20 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
+ '@vitest/mocker@5.0.3':
+ resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/spy@5.0.3':
+ resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
+
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@@ -3643,6 +3719,16 @@ packages:
'@vue/shared@3.5.43':
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
+ '@vue/test-utils@2.5.1':
+ resolution: {integrity: sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==}
+ peerDependencies:
+ '@vue/compiler-dom': 3.x
+ '@vue/server-renderer': 3.x
+ vue: 3.x
+ peerDependenciesMeta:
+ '@vue/server-renderer':
+ optional: true
+
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
@@ -3733,6 +3819,10 @@ packages:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
+ abbrev@5.0.0:
+ resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -3751,11 +3841,6 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
- acorn@7.4.1:
- resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
- engines: {node: '>=0.4.0'}
- hasBin: true
-
acorn@8.19.0:
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
engines: {node: '>=0.4.0'}
@@ -3841,20 +3926,14 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
- asap@2.0.6:
- resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
-
- assert-never@1.4.0:
- resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
- ast-types@0.16.3:
- resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==}
- engines: {node: '>=4'}
-
ast-walker-scope@0.9.0:
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
engines: {node: '>=20.19.0'}
@@ -3887,10 +3966,6 @@ packages:
react-native-b4a:
optional: true
- babel-walk@3.0.0-canary-5:
- resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
- engines: {node: '>= 10.0.0'}
-
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -3996,6 +4071,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
+
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -4050,6 +4129,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@6.3.0:
+ resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
+ engines: {node: '>=18'}
+
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
@@ -4063,9 +4146,6 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
- character-parser@2.2.0:
- resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==}
-
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
@@ -4139,6 +4219,10 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
+ commander@14.0.3:
+ resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
+ engines: {node: '>=20'}
+
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -4164,13 +4248,13 @@ packages:
confbox@0.3.1:
resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==}
+ config-chain@1.1.13:
+ resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
+
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
- constantinople@4.0.1:
- resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
-
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
@@ -4398,9 +4482,6 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
- doctypes@1.1.0:
- resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==}
-
docus@5.14.0:
resolution: {integrity: sha512-Zle9z54Wsc3pU12yLincka2SFmY2QCwhWxUW2r8SfcriIoNYKBxHlXByRRA2/6iZxnk8x+/yuEnkUkyk50ZCsQ==}
peerDependencies:
@@ -4454,6 +4535,11 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ editorconfig@3.0.2:
+ resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==}
+ engines: {node: '>=20'}
+ hasBin: true
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -4641,9 +4727,6 @@ packages:
jiti:
optional: true
- esm-resolve@1.0.11:
- resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
-
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -4710,6 +4793,10 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
express-rate-limit@8.7.1:
resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==}
engines: {node: '>= 16'}
@@ -4978,6 +5065,10 @@ packages:
h3@1.15.11:
resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
+ happy-dom@20.14.5:
+ resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
+ engines: {node: '>=20.0.0'}
+
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
@@ -4989,9 +5080,6 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
- hash-sum@2.0.0:
- resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==}
-
hashery@1.5.1:
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
engines: {node: '>=20'}
@@ -5203,9 +5291,6 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
- is-expression@4.0.0:
- resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==}
-
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -5249,19 +5334,12 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
- is-promise@2.2.2:
- resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
-
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
- is-regex@1.2.1:
- resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
- engines: {node: '>= 0.4'}
-
is-ssh@1.4.1:
resolution: {integrity: sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==}
@@ -5327,8 +5405,13 @@ packages:
jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
- js-stringify@1.0.2:
- resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==}
+ js-beautify@2.0.3:
+ resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ js-cookie@3.0.8:
+ resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -5383,9 +5466,6 @@ packages:
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
- jstransformer@1.0.0:
- resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
-
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
@@ -5620,10 +5700,6 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lru-cache@8.0.5:
- resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
- engines: {node: '>=16.14'}
-
magic-regexp@0.10.0:
resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==}
@@ -5963,6 +6039,11 @@ packages:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'}
+ nopt@10.0.1:
+ resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ hasBin: true
+
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -6355,6 +6436,16 @@ packages:
pkg-types@2.3.3:
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.63.0:
+ resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6562,9 +6653,6 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
- promise@7.3.1:
- resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
-
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
@@ -6610,6 +6698,9 @@ packages:
prosemirror-view@1.42.6:
resolution: {integrity: sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==}
+ proto-list@1.2.4:
+ resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
+
protocols@2.0.2:
resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==}
@@ -6622,42 +6713,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
- pug-attrs@3.0.0:
- resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
-
- pug-code-gen@3.0.4:
- resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==}
-
- pug-error@2.1.0:
- resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==}
-
- pug-filters@4.0.0:
- resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==}
-
- pug-lexer@5.0.1:
- resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==}
-
- pug-linker@4.0.0:
- resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==}
-
- pug-load@3.0.0:
- resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==}
-
- pug-parser@6.0.0:
- resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==}
-
- pug-runtime@3.0.1:
- resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==}
-
- pug-strip-comments@2.0.0:
- resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==}
-
- pug-walk@2.0.0:
- resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==}
-
- pug@3.0.4:
- resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==}
-
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -6723,10 +6778,6 @@ packages:
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
engines: {node: '>= 20.19.0'}
- recast@0.23.21:
- resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
- engines: {node: '>= 4'}
-
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
@@ -7214,6 +7265,10 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@6.2.1:
+ resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==}
+ engines: {node: '>=20.0.0'}
+
tinyclip@0.1.15:
resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==}
engines: {node: ^16.14.0 || >= 17.3.0}
@@ -7238,9 +7293,6 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
- token-stream@1.0.0:
- resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==}
-
tosource@2.0.0-alpha.3:
resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==}
engines: {node: '>=10'}
@@ -7267,9 +7319,6 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
- ts-map@1.0.3:
- resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
-
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -7798,9 +7847,46 @@ packages:
yaml:
optional: true
- void-elements@3.1.0:
- resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
- engines: {node: '>=0.10.0'}
+ vitest@5.0.3:
+ resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
+ engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 5.0.3
+ '@vitest/browser-preview': 5.0.3
+ '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
+ '@vitest/coverage-istanbul': 5.0.3
+ '@vitest/coverage-v8': 5.0.3
+ '@vitest/ui': 5.0.3
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.4.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
vscode-uri@3.2.0:
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
@@ -7833,22 +7919,12 @@ packages:
vue-devtools-stub@0.1.0:
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
- vue-docgen-api@4.79.2:
- resolution: {integrity: sha512-n9ENAcs+40awPZMsas7STqjkZiVlIjxIKgiJr5rSohDP0/JCrD9VtlzNojafsA1MChm/hz2h3PDtUedx3lbgfA==}
- peerDependencies:
- vue: '>=2'
-
vue-i18n@11.4.13:
resolution: {integrity: sha512-2G7LC9XcdZ0IljFib7lKGHNiBBOTv6HO+2bnBaOiQgDYZ+uUnqSpuED5DbBo1t7su50o/WRGn+ZfDQTGd26xEw==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
- vue-inbrowser-compiler-independent-utils@4.71.1:
- resolution: {integrity: sha512-K3wt3iVmNGaFEOUR4JIThQRWfqokxLfnPslD41FDZB2ajXp789+wCqJyGYlIFsvEQ2P61PInw6/ph5iiqg51gg==}
- peerDependencies:
- vue: '>=2'
-
vue-router@5.3.1:
resolution: {integrity: sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==}
peerDependencies:
@@ -7893,6 +7969,10 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -7914,9 +7994,10 @@ packages:
engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true
- with@7.0.2:
- resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==}
- engines: {node: '>= 10.0.0'}
+ why-is-node-running@3.2.1:
+ resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
+ engines: {node: '>=20.11'}
+ hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
@@ -8069,11 +8150,11 @@ snapshots:
dependencies:
json-schema: 0.4.0
- '@ai-sdk/vue@4.0.128(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)':
+ '@ai-sdk/vue@4.0.128(@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)':
dependencies:
'@ai-sdk/provider-utils': 5.0.54(zod@4.6.5)
ai: 7.0.128(zod@4.6.5)
- swrv: 1.3.0(vue@3.5.43(typescript@5.9.3))
+ swrv: 1.3.0(@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies:
- '@vue/test-utils'
@@ -8260,10 +8341,11 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
- '@bomb.sh/tab@0.0.19(cac@6.7.14)(citty@0.2.2)':
+ '@bomb.sh/tab@0.0.19(cac@6.7.14)(citty@0.2.2)(commander@14.0.3)':
optionalDependencies:
cac: 6.7.14
citty: 0.2.2
+ commander: 14.0.3
'@cacheable/memory@2.2.0':
dependencies:
@@ -9112,6 +9194,10 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
+ '@lucide/vue@1.52.0(vue@3.5.43(typescript@5.9.3))':
+ dependencies:
+ vue: 3.5.43(typescript@5.9.3)
+
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.29.7
@@ -9209,9 +9295,9 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.3
- '@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(@parcel/watcher@2.6.0)(cac@6.7.14)(magicast@0.5.5)':
+ '@nuxt/cli@3.37.0(@nuxt/schema@4.4.8)(@parcel/watcher@2.6.0)(cac@6.7.14)(commander@14.0.3)(magicast@0.5.5)':
dependencies:
- '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2)
+ '@bomb.sh/tab': 0.0.19(cac@6.7.14)(citty@0.2.2)(commander@14.0.3)
'@clack/prompts': 1.8.1
c12: 3.3.4(magicast@0.5.5)
citty: 0.2.2
@@ -9248,10 +9334,10 @@ snapshots:
- magicast
- supports-color
- '@nuxt/content@3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@0.30.21)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
+ '@nuxt/content@3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@1.4.3)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- '@nuxtjs/mdc': 0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxtjs/mdc': 0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
'@shikijs/langs': 4.5.0
'@sqlite.org/sqlite-wasm': 3.53.0-build1
'@standard-schema/spec': 1.1.0
@@ -9291,7 +9377,7 @@ snapshots:
std-env: 4.3.0
tinyglobby: 0.2.17
ufo: 1.6.4
- unctx: 3.0.1(magic-string@0.30.21)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ unctx: 3.0.1(magic-string@1.4.3)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
unified: 11.0.5
unist-util-stringify-position: 4.0.0
unist-util-visit: 5.1.0
@@ -9360,9 +9446,9 @@ snapshots:
- rolldown
- unplugin
- '@nuxt/devtools-kit@4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
+ '@nuxt/devtools-kit@4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
tinyexec: 1.3.1
vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
@@ -9377,9 +9463,9 @@ snapshots:
- rolldown
- unplugin
- '@nuxt/devtools-kit@4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
+ '@nuxt/devtools-kit@4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
tinyexec: 1.3.1
vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
transitivePeerDependencies:
@@ -9578,10 +9664,10 @@ snapshots:
- vite
- vue
- '@nuxt/image@2.1.0(@nuxt/schema@4.6.0)(@types/node@26.6.4)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1))':
+ '@nuxt/image@2.1.0(@nuxt/schema@4.6.0)(@types/node@26.6.4)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1))':
dependencies:
'@noble/hashes': 2.4.0
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
defu: 6.1.7
h3: 1.15.11
image-meta: 0.2.2
@@ -9664,39 +9750,6 @@ snapshots:
- magic-string
- unplugin
- '@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))':
- dependencies:
- consola: 3.4.2
- defu: 6.1.7
- destr: 2.0.5
- errx: 0.2.2
- exsolve: 1.1.3
- ignore: 7.0.12
- klona: 2.0.6
- microdiff: 1.6.0
- nostics: 1.3.0
- package-manager-detector: 1.9.0
- pathe: 2.0.3
- rc9: 3.1.0
- scule: 1.3.0
- std-env: 4.3.0
- tinyexec: 1.3.1
- tinyglobby: 0.2.17
- unctx: 3.0.1(magic-string@0.30.21)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- verkit: 0.5.0
- optionalDependencies:
- '@nuxt/schema': 4.6.0
- confbox: 0.2.4
- dotenv: 17.4.2
- giget: 3.3.1
- jiti: 2.7.0
- mlly: 1.8.2
- oxc-parser: 0.133.0
- rolldown: 1.2.12
- transitivePeerDependencies:
- - magic-string
- - unplugin
-
'@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.139.0)(rolldown@1.2.12)(unplugin@2.3.11)':
dependencies:
consola: 3.4.2
@@ -9796,6 +9849,39 @@ snapshots:
- magic-string
- unplugin
+ '@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))':
+ dependencies:
+ consola: 3.4.2
+ defu: 6.1.7
+ destr: 2.0.5
+ errx: 0.2.2
+ exsolve: 1.1.3
+ ignore: 7.0.12
+ klona: 2.0.6
+ microdiff: 1.6.0
+ nostics: 1.3.0
+ package-manager-detector: 1.9.0
+ pathe: 2.0.3
+ rc9: 3.1.0
+ scule: 1.3.0
+ std-env: 4.3.0
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ unctx: 3.0.1(magic-string@1.4.3)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ verkit: 0.5.0
+ optionalDependencies:
+ '@nuxt/schema': 4.6.0
+ confbox: 0.2.4
+ dotenv: 17.4.2
+ giget: 3.3.1
+ jiti: 2.7.0
+ mlly: 1.8.2
+ oxc-parser: 0.141.0
+ rolldown: 1.2.12
+ transitivePeerDependencies:
+ - magic-string
+ - unplugin
+
'@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))':
dependencies:
consola: 3.4.2
@@ -9829,7 +9915,7 @@ snapshots:
- magic-string
- unplugin
- '@nuxt/nitro-server@4.4.8(7c5f3e276f30fa96660ce41fb115fe08)':
+ '@nuxt/nitro-server@4.4.8(de6d13e879b665d0b4d1abf7f9393f0b)':
dependencies:
'@nuxt/devalue': 2.0.2
'@nuxt/kit': 4.4.8(magicast@0.5.5)
@@ -9847,7 +9933,7 @@ snapshots:
klona: 2.0.6
mocked-exports: 0.1.1
nitropack: 2.13.4(@parcel/watcher@2.6.0)(better-sqlite3@12.11.1)(oxc-parser@0.133.0)(rolldown@1.2.12)(srvx@0.11.22)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
nypm: 0.6.10
ohash: 2.0.12
pathe: 2.0.3
@@ -9934,7 +10020,7 @@ snapshots:
rc9: 3.1.0
std-env: 4.3.0
- '@nuxt/ui@4.11.3(8fcd5060c01dbb08a620ed8a677cd1b0)':
+ '@nuxt/ui@4.11.3(1ab40aaa47b88302858324756ba27a26)':
dependencies:
'@floating-ui/dom': 1.8.0
'@iconify/vue': 5.0.3(vue@3.5.43(typescript@5.9.3))
@@ -9996,14 +10082,14 @@ snapshots:
typescript: 5.9.3
ufo: 1.6.4
unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- unplugin-auto-import: 21.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(@vueuse/core@14.4.0(vue@3.5.43(typescript@5.9.3)))(esbuild@0.28.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- unplugin-vue-components: 32.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
+ unplugin-auto-import: 21.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(@vueuse/core@14.4.0(vue@3.5.43(typescript@5.9.3)))(esbuild@0.28.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ unplugin-vue-components: 32.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
vaul-vue: 0.4.1(reka-ui@2.10.5(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
vue-component-type-helpers: 3.3.12
optionalDependencies:
'@internationalized/date': 3.12.4
'@internationalized/number': 3.6.8
- '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@0.30.21)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@1.4.3)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
ai: 7.0.128(zod@4.6.5)
vue-router: 5.3.1(@vue/compiler-sfc@3.5.43)(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
zod: 4.6.5
@@ -10062,7 +10148,7 @@ snapshots:
- vue
- webpack
- '@nuxt/vite-builder@4.4.8(8d87756da47842bbb77226aff6f186dc)':
+ '@nuxt/vite-builder@4.4.8(691ba7ab3b53df2d8a6011c8c80590d7)':
dependencies:
'@nuxt/kit': 4.4.8(magicast@0.5.5)
'@rollup/plugin-replace': 6.0.3(rollup@4.64.0)
@@ -10080,7 +10166,7 @@ snapshots:
magic-string: 0.30.21
mlly: 1.8.2
mocked-exports: 0.1.1
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
nypm: 0.6.10
pathe: 2.0.3
pkg-types: 2.3.3
@@ -10214,10 +10300,10 @@ snapshots:
- vue
- webpack
- '@nuxtjs/mcp-toolkit@0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)':
+ '@nuxtjs/mcp-toolkit@0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)':
dependencies:
'@modelcontextprotocol/sdk': 1.32.1(zod@4.6.5)
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
'@vitejs/plugin-vue': 6.0.9(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
h3: 1.15.11
tinyglobby: 0.2.17
@@ -10242,9 +10328,9 @@ snapshots:
- supports-color
- unplugin
- '@nuxtjs/mdc@0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))':
+ '@nuxtjs/mdc@0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))':
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
'@shikijs/core': 4.5.0
'@shikijs/engine-javascript': 4.5.0
'@shikijs/langs': 4.5.0
@@ -10301,15 +10387,15 @@ snapshots:
- supports-color
- unplugin
- '@nuxtjs/robots@6.2.4(22ba0ed89dd6406cbb938bb1364fc5c5)':
+ '@nuxtjs/robots@6.2.4(1fa5557b7a9b0a358862a4710025e777)':
dependencies:
'@fingerprintjs/botd': 2.0.0
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
consola: 3.4.2
defu: 6.1.7
h3: 1.15.11
- nuxt-site-config: 4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5)
- nuxtseo-shared: 5.3.16(744ff7fd9a152b5ba76bd41516e4c401)
+ nuxt-site-config: 4.2.3(1fa5557b7a9b0a358862a4710025e777)
+ nuxtseo-shared: 5.3.16(2583b91d8de450fb4fcddd6ee273ef79)
pathe: 2.0.3
pkg-types: 2.3.3
ufo: 1.6.4
@@ -10330,13 +10416,13 @@ snapshots:
- vite
- vue
- '@nuxtjs/sitemap@8.6.1(a69eb50999abd1859c12f282dbdb225a)':
+ '@nuxtjs/sitemap@8.6.1(abe2f75b5aa07b6602caa1860dcba6f9)':
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
consola: 3.4.2
defu: 6.1.7
- nuxt-site-config: 4.2.3(6be24db654c942a14816f18d0d34b432)
- nuxtseo-shared: 5.3.16(f4b45a8c7d2f2bc1b5d16b0c37217a1c)
+ nuxt-site-config: 4.2.3(0155943dcfcb3e8f1e6ee7ac6748e13b)
+ nuxtseo-shared: 5.3.16(be474f164f65517a791b10007c1d9f36)
ofetch: 1.5.1
oxc-parser: 0.141.0
oxc-walker: 1.1.1(@oxc-project/types@0.152.0)(oxc-parser@0.141.0)(rolldown@1.2.12)
@@ -10363,6 +10449,8 @@ snapshots:
- vite
- vue
+ '@one-ini/wasm@0.2.1': {}
+
'@oxc-minify/binding-android-arm-eabi@0.133.0':
optional: true
@@ -10879,6 +10967,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.63.0':
+ dependencies:
+ playwright: 1.63.0
+
'@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.6':
@@ -11530,10 +11622,17 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
+ '@types/deep-eql@4.0.2': {}
+
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -11574,6 +11673,12 @@ snapshots:
'@types/web-bluetooth@0.0.21': {}
+ '@types/whatwg-mimetype@3.0.2': {}
+
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 26.6.4
+
'@typescript-eslint/project-service@8.71.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3)
@@ -11712,6 +11817,17 @@ snapshots:
vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
vue: 3.5.43(typescript@5.9.3)
+ '@vitest/mocker@5.0.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ '@vitest/spy': 5.0.3
+ estree-walker: 3.0.3
+ magic-string: 1.4.3
+ optionalDependencies:
+ vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
+
+ '@vitest/spy@5.0.3': {}
+
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
@@ -11848,6 +11964,15 @@ snapshots:
'@vue/shared@3.5.43': {}
+ '@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-dom': 3.5.43
+ js-beautify: 2.0.3
+ vue: 3.5.43(typescript@5.9.3)
+ vue-component-type-helpers: 3.3.12
+ optionalDependencies:
+ '@vue/server-renderer': 3.5.43
+
'@vueuse/core@10.11.1(vue@3.5.43(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
@@ -11907,6 +12032,8 @@ snapshots:
abbrev@3.0.1: {}
+ abbrev@5.0.0: {}
+
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -11924,8 +12051,6 @@ snapshots:
dependencies:
acorn: 8.19.0
- acorn@7.4.1: {}
-
acorn@8.19.0: {}
agent-base@7.1.4: {}
@@ -12014,19 +12139,13 @@ snapshots:
array-union@2.1.0: {}
- asap@2.0.6: {}
-
- assert-never@1.4.0: {}
+ assertion-error@2.0.1: {}
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.9
pathe: 2.0.3
- ast-types@0.16.3:
- dependencies:
- tslib: 2.8.1
-
ast-walker-scope@0.9.0:
dependencies:
'@babel/parser': 7.29.9
@@ -12054,10 +12173,6 @@ snapshots:
b4a@1.9.0: {}
- babel-walk@3.0.0-canary-5:
- dependencies:
- '@babel/types': 7.29.8
-
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -12160,6 +12275,10 @@ snapshots:
buffer-from@1.1.2: {}
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 26.6.4
+
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -12231,6 +12350,8 @@ snapshots:
ccount@2.0.1: {}
+ chai@6.3.0: {}
+
char-regex@1.0.2: {}
character-entities-html4@2.1.0: {}
@@ -12239,10 +12360,6 @@ snapshots:
character-entities@2.0.2: {}
- character-parser@2.2.0:
- dependencies:
- is-regex: 1.2.1
-
character-reference-invalid@2.0.1: {}
chardet@2.2.0: {}
@@ -12301,6 +12418,8 @@ snapshots:
commander@11.1.0: {}
+ commander@14.0.3: {}
+
commander@2.20.3: {}
commondir@1.0.1: {}
@@ -12323,12 +12442,12 @@ snapshots:
confbox@0.3.1: {}
- consola@3.4.2: {}
-
- constantinople@4.0.1:
+ config-chain@1.1.13:
dependencies:
- '@babel/parser': 7.29.9
- '@babel/types': 7.29.8
+ ini: 1.3.8
+ proto-list: 1.2.4
+
+ consola@3.4.2: {}
content-disposition@1.1.0: {}
@@ -12518,26 +12637,24 @@ snapshots:
dependencies:
path-type: 4.0.0
- doctypes@1.1.0: {}
-
- docus@5.14.0(db3a3babb3e1a5f38f7fe64c400fed97):
+ docus@5.14.0(f92feb3e0732c978b98957e5210d7bcc):
dependencies:
'@ai-sdk/gateway': 4.0.104(zod@4.6.5)
'@ai-sdk/mcp': 2.0.67(zod@4.6.5)
- '@ai-sdk/vue': 4.0.128(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
+ '@ai-sdk/vue': 4.0.128(@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
'@comark/vue': 0.7.0(shiki@4.5.0)(vue@3.5.43(typescript@5.9.3))
'@iconify-json/lucide': 1.2.140
'@iconify-json/simple-icons': 1.2.99
'@iconify-json/vscode-icons': 1.2.85
- '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@0.30.21)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- '@nuxt/image': 2.1.0(@nuxt/schema@4.6.0)(@types/node@26.6.4)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1))
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- '@nuxt/ui': 4.11.3(8fcd5060c01dbb08a620ed8a677cd1b0)
+ '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@1.4.3)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ '@nuxt/image': 2.1.0(@nuxt/schema@4.6.0)(@types/node@26.6.4)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(unstorage@1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/ui': 4.11.3(1ab40aaa47b88302858324756ba27a26)
'@nuxtjs/i18n': 10.6.0(@nuxt/schema@4.6.0)(@vue/compiler-dom@3.5.43)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(rolldown@1.2.12)(rollup@4.64.0)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
- '@nuxtjs/mcp-toolkit': 0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
- '@nuxtjs/mdc': 0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- '@nuxtjs/robots': 6.2.4(22ba0ed89dd6406cbb938bb1364fc5c5)
- '@nuxtjs/sitemap': 8.6.1(a69eb50999abd1859c12f282dbdb225a)
+ '@nuxtjs/mcp-toolkit': 0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
+ '@nuxtjs/mdc': 0.23.2(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxtjs/robots': 6.2.4(1fa5557b7a9b0a358862a4710025e777)
+ '@nuxtjs/sitemap': 8.6.1(abe2f75b5aa07b6602caa1860dcba6f9)
'@shikijs/core': 4.5.0
'@shikijs/engine-javascript': 4.5.0
'@shikijs/langs': 4.5.0
@@ -12552,11 +12669,11 @@ snapshots:
git-url-parse: 16.1.0
h3: 1.15.11
motion-v: 2.6.0(@vueuse/core@15.0.0(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3))
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
- nuxt-agent-discovery: 0.7.0(e9569477de85d8ec71c9f415afdb7fbf)
- nuxt-llms: 0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- nuxt-og-image: 6.9.1(370b99bbe49e4fc4310c887835077271)
- nuxt-schema-org: 6.4.3(06339588bd6230de3030fc270006c9ef)
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nuxt-agent-discovery: 0.7.0(ca46561f50adf4368575834e85d08d49)
+ nuxt-llms: 0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ nuxt-og-image: 6.9.1(97b88f392d295c90a093efa465ae7a22)
+ nuxt-schema-org: 6.4.3(68150d0998eb177d5aacae8cb44f8919)
pathe: 2.0.3
pkg-types: 2.3.3
scule: 1.3.0
@@ -12743,6 +12860,13 @@ snapshots:
eastasianwidth@0.2.0: {}
+ editorconfig@3.0.2:
+ dependencies:
+ '@one-ini/wasm': 0.2.1
+ commander: 14.0.3
+ minimatch: 10.2.6
+ semver: 7.8.5
+
ee-first@1.1.1: {}
electron-to-chromium@1.5.445: {}
@@ -13001,8 +13125,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- esm-resolve@1.0.11: {}
-
espree@11.2.0:
dependencies:
acorn: 8.19.0
@@ -13067,6 +13189,8 @@ snapshots:
expand-template@2.0.3: {}
+ expect-type@1.4.0: {}
+
express-rate-limit@8.7.1(express@5.2.1):
dependencies:
debug: 4.4.3
@@ -13418,6 +13542,19 @@ snapshots:
ufo: 1.6.4
uncrypto: 0.1.3
+ happy-dom@20.14.5:
+ dependencies:
+ '@types/node': 26.6.4
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.2
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.22.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
@@ -13428,8 +13565,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
- hash-sum@2.0.0: {}
-
hashery@1.5.1:
dependencies:
hookified: 1.15.1
@@ -13718,11 +13853,6 @@ snapshots:
is-docker@3.0.0: {}
- is-expression@4.0.0:
- dependencies:
- acorn: 7.4.1
- object-assign: 4.1.1
-
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -13752,21 +13882,12 @@ snapshots:
is-plain-obj@4.1.0: {}
- is-promise@2.2.2: {}
-
is-promise@4.0.0: {}
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.9
- is-regex@1.2.1:
- dependencies:
- call-bound: 1.0.4
- gopd: 1.2.0
- has-tostringtag: 1.0.2
- hasown: 2.0.4
-
is-ssh@1.4.1:
dependencies:
protocols: 2.0.2
@@ -13829,7 +13950,15 @@ snapshots:
jose@6.2.12: {}
- js-stringify@1.0.2: {}
+ js-beautify@2.0.3:
+ dependencies:
+ config-chain: 1.1.13
+ editorconfig: 3.0.2
+ glob: 13.0.6
+ js-cookie: 3.0.8
+ nopt: 10.0.1
+
+ js-cookie@3.0.8: {}
js-tokens@10.0.0: {}
@@ -13878,11 +14007,6 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
- jstransformer@1.0.0:
- dependencies:
- is-promise: 2.2.2
- promise: 7.3.1
-
keyv@5.6.0:
dependencies:
'@keyv/serialize': 1.1.1
@@ -14077,8 +14201,6 @@ snapshots:
dependencies:
yallist: 3.1.1
- lru-cache@8.0.5: {}
-
magic-regexp@0.10.0:
dependencies:
estree-walker: 3.0.3
@@ -14699,6 +14821,10 @@ snapshots:
node-releases@2.0.57: {}
+ nopt@10.0.1:
+ dependencies:
+ abbrev: 5.0.0
+
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
@@ -14720,19 +14846,19 @@ snapshots:
dependencies:
boolbase: 1.0.0
- nuxt-agent-discovery@0.7.0(e9569477de85d8ec71c9f415afdb7fbf):
+ nuxt-agent-discovery@0.7.0(ca46561f50adf4368575834e85d08d49):
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
defu: 6.1.7
pathe: 2.0.3
ufo: 1.6.4
yaml: 2.9.1
optionalDependencies:
- '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@0.30.21)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- '@nuxtjs/mcp-toolkit': 0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
- '@nuxtjs/robots': 6.2.4(22ba0ed89dd6406cbb938bb1364fc5c5)
- '@nuxtjs/sitemap': 8.6.1(a69eb50999abd1859c12f282dbdb225a)
- nuxt-llms: 0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/content': 3.16.1(@nuxt/schema@4.6.0)(@oxc-project/types@0.152.0)(better-sqlite3@12.11.1)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(magic-string@1.4.3)(magicast@0.5.5)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ '@nuxtjs/mcp-toolkit': 0.22.0(@nuxt/schema@4.6.0)(@vue/compiler-sfc@3.5.43)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(h3@1.15.11)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))(zod@4.6.5)
+ '@nuxtjs/robots': 6.2.4(1fa5557b7a9b0a358862a4710025e777)
+ '@nuxtjs/sitemap': 8.6.1(abe2f75b5aa07b6602caa1860dcba6f9)
+ nuxt-llms: 0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
transitivePeerDependencies:
- '@nuxt/schema'
- confbox
@@ -14772,9 +14898,9 @@ snapshots:
nuxt-define@1.0.0: {}
- nuxt-llms@0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))):
+ nuxt-llms@0.2.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))):
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
transitivePeerDependencies:
- '@nuxt/schema'
- confbox
@@ -14787,7 +14913,7 @@ snapshots:
- rolldown
- unplugin
- nuxt-og-image@6.9.1(370b99bbe49e4fc4310c887835077271):
+ nuxt-og-image@6.9.1(97b88f392d295c90a093efa465ae7a22):
dependencies:
'@clack/prompts': 1.8.1
'@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
@@ -14803,8 +14929,8 @@ snapshots:
magic-string: 1.4.3
magicast: 0.5.5
mocked-exports: 0.1.1
- nuxt-site-config: 4.2.3(538206e48ced1a5d386ca47224beb9fa)
- nuxtseo-shared: 5.3.16(9bac2b3fce822bb4252d84274d0ded3b)
+ nuxt-site-config: 4.2.3(6410502bfc34806684a6b989ba77e2dc)
+ nuxtseo-shared: 5.3.16(004168448f598b8a8a765fbd51bb6c3a)
nypm: 0.6.10
object-identity: 0.2.3
ofetch: 1.5.1
@@ -14825,6 +14951,7 @@ snapshots:
'@takumi-rs/core': 2.14.0(csstype@3.2.3)
fontless: 0.2.1(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
nitropack: 2.13.4(@parcel/watcher@2.6.0)(better-sqlite3@12.11.1)(oxc-parser@0.133.0)(rolldown@1.2.12)(srvx@0.11.22)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ playwright-core: 1.63.0
sharp: 0.35.5(@types/node@26.6.4)
tailwindcss: 4.3.3
unifont: 0.7.5
@@ -14851,12 +14978,12 @@ snapshots:
- vue
- webpack
- nuxt-schema-org@6.4.3(06339588bd6230de3030fc270006c9ef):
+ nuxt-schema-org@6.4.3(68150d0998eb177d5aacae8cb44f8919):
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
defu: 6.1.7
- nuxt-site-config: 4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5)
- nuxtseo-shared: 5.3.16(744ff7fd9a152b5ba76bd41516e4c401)
+ nuxt-site-config: 4.2.3(1fa5557b7a9b0a358862a4710025e777)
+ nuxtseo-shared: 5.3.16(2583b91d8de450fb4fcddd6ee273ef79)
pkg-types: 2.3.3
ufo: 1.6.4
optionalDependencies:
@@ -14878,9 +15005,9 @@ snapshots:
- vite
- vue
- nuxt-site-config-kit@4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3)):
+ nuxt-site-config-kit@4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3)):
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
std-env: 4.3.0
ufo: 1.6.4
@@ -14897,9 +15024,9 @@ snapshots:
- unplugin
- vue
- nuxt-site-config-kit@4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3)):
+ nuxt-site-config-kit@4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3)):
dependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
std-env: 4.3.0
ufo: 1.6.4
@@ -14935,15 +15062,15 @@ snapshots:
- unplugin
- vue
- nuxt-site-config@4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5):
+ nuxt-site-config@4.2.3(0155943dcfcb3e8f1e6ee7ac6748e13b):
dependencies:
'@nuxt/devalue': 2.0.2
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
consola: 3.4.2
defu: 6.1.7
h3: 1.15.11
- nuxt-site-config-kit: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3))
- nuxtseo-shared: 5.3.16(744ff7fd9a152b5ba76bd41516e4c401)
+ nuxt-site-config-kit: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3))
+ nuxtseo-shared: 5.3.16(be474f164f65517a791b10007c1d9f36)
pathe: 2.0.3
pkg-types: 2.3.3
site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
@@ -14964,7 +15091,36 @@ snapshots:
- vite
- zod
- nuxt-site-config@4.2.3(538206e48ced1a5d386ca47224beb9fa):
+ nuxt-site-config@4.2.3(1fa5557b7a9b0a358862a4710025e777):
+ dependencies:
+ '@nuxt/devalue': 2.0.2
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ consola: 3.4.2
+ defu: 6.1.7
+ h3: 1.15.11
+ nuxt-site-config-kit: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3))
+ nuxtseo-shared: 5.3.16(2583b91d8de450fb4fcddd6ee273ef79)
+ pathe: 2.0.3
+ pkg-types: 2.3.3
+ site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
+ ufo: 1.6.4
+ vue: 3.5.43(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@nuxt/schema'
+ - confbox
+ - dotenv
+ - giget
+ - jiti
+ - magic-string
+ - mlly
+ - nuxt
+ - oxc-parser
+ - rolldown
+ - unplugin
+ - vite
+ - zod
+
+ nuxt-site-config@4.2.3(6410502bfc34806684a6b989ba77e2dc):
dependencies:
'@nuxt/devalue': 2.0.2
'@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
@@ -14972,7 +15128,7 @@ snapshots:
defu: 6.1.7
h3: 1.15.11
nuxt-site-config-kit: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3))
- nuxtseo-shared: 5.3.16(9bac2b3fce822bb4252d84274d0ded3b)
+ nuxtseo-shared: 5.3.16(004168448f598b8a8a765fbd51bb6c3a)
pathe: 2.0.3
pkg-types: 2.3.3
site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
@@ -14993,45 +15149,16 @@ snapshots:
- vite
- zod
- nuxt-site-config@4.2.3(6be24db654c942a14816f18d0d34b432):
- dependencies:
- '@nuxt/devalue': 2.0.2
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- consola: 3.4.2
- defu: 6.1.7
- h3: 1.15.11
- nuxt-site-config-kit: 4.2.3(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vue@3.5.43(typescript@5.9.3))
- nuxtseo-shared: 5.3.16(f4b45a8c7d2f2bc1b5d16b0c37217a1c)
- pathe: 2.0.3
- pkg-types: 2.3.3
- site-config-stack: 4.2.3(vue@3.5.43(typescript@5.9.3))
- ufo: 1.6.4
- vue: 3.5.43(typescript@5.9.3)
- transitivePeerDependencies:
- - '@nuxt/schema'
- - confbox
- - dotenv
- - giget
- - jiti
- - magic-string
- - mlly
- - nuxt
- - oxc-parser
- - rolldown
- - unplugin
- - vite
- - zod
-
- nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1):
+ nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1):
dependencies:
'@dxup/nuxt': 0.4.1(@nuxt/schema@4.4.8)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(typescript@5.9.3)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- '@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(@parcel/watcher@2.6.0)(cac@6.7.14)(magicast@0.5.5)
+ '@nuxt/cli': 3.37.0(@nuxt/schema@4.4.8)(@parcel/watcher@2.6.0)(cac@6.7.14)(commander@14.0.3)(magicast@0.5.5)
'@nuxt/devtools': 3.4.2(@nuxt/schema@4.4.8)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3))
'@nuxt/kit': 4.4.8(magicast@0.5.5)
- '@nuxt/nitro-server': 4.4.8(7c5f3e276f30fa96660ce41fb115fe08)
+ '@nuxt/nitro-server': 4.4.8(de6d13e879b665d0b4d1abf7f9393f0b)
'@nuxt/schema': 4.4.8
'@nuxt/telemetry': 2.9.1(@nuxt/kit@4.4.8(magicast@0.5.5))
- '@nuxt/vite-builder': 4.4.8(8d87756da47842bbb77226aff6f186dc)
+ '@nuxt/vite-builder': 4.4.8(691ba7ab3b53df2d8a6011c8c80590d7)
'@unhead/vue': 2.1.17(vue@3.5.43(typescript@5.9.3))
'@vue/shared': 3.5.43
chokidar: 5.0.0
@@ -15162,41 +15289,7 @@ snapshots:
- xml2js
- yaml
- nuxtseo-shared@5.3.16(744ff7fd9a152b5ba76bd41516e4c401):
- dependencies:
- '@clack/prompts': 1.8.1
- '@nuxt/devtools-kit': 4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
- '@nuxt/schema': 4.6.0
- birpc: 4.2.0
- consola: 3.4.2
- defu: 6.1.7
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
- nypm: 0.6.10
- ofetch: 1.5.1
- pathe: 2.0.3
- pkg-types: 2.3.3
- radix3: 1.1.2
- sirv: 3.0.2
- std-env: 4.3.0
- ufo: 1.6.4
- vue: 3.5.43(typescript@5.9.3)
- optionalDependencies:
- nuxt-site-config: 4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5)
- zod: 4.6.5
- transitivePeerDependencies:
- - confbox
- - dotenv
- - giget
- - jiti
- - magic-string
- - mlly
- - oxc-parser
- - rolldown
- - unplugin
- - vite
-
- nuxtseo-shared@5.3.16(9bac2b3fce822bb4252d84274d0ded3b):
+ nuxtseo-shared@5.3.16(004168448f598b8a8a765fbd51bb6c3a):
dependencies:
'@clack/prompts': 1.8.1
'@nuxt/devtools-kit': 4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
@@ -15205,7 +15298,7 @@ snapshots:
birpc: 4.2.0
consola: 3.4.2
defu: 6.1.7
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
nypm: 0.6.10
ofetch: 1.5.1
pathe: 2.0.3
@@ -15216,7 +15309,7 @@ snapshots:
ufo: 1.6.4
vue: 3.5.43(typescript@5.9.3)
optionalDependencies:
- nuxt-site-config: 4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5)
+ nuxt-site-config: 4.2.3(1fa5557b7a9b0a358862a4710025e777)
zod: 4.6.5
transitivePeerDependencies:
- confbox
@@ -15230,16 +15323,16 @@ snapshots:
- unplugin
- vite
- nuxtseo-shared@5.3.16(f4b45a8c7d2f2bc1b5d16b0c37217a1c):
+ nuxtseo-shared@5.3.16(2583b91d8de450fb4fcddd6ee273ef79):
dependencies:
'@clack/prompts': 1.8.1
- '@nuxt/devtools-kit': 4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/devtools-kit': 4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
'@nuxt/schema': 4.6.0
birpc: 4.2.0
consola: 3.4.2
defu: 6.1.7
- nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
nypm: 0.6.10
ofetch: 1.5.1
pathe: 2.0.3
@@ -15250,7 +15343,41 @@ snapshots:
ufo: 1.6.4
vue: 3.5.43(typescript@5.9.3)
optionalDependencies:
- nuxt-site-config: 4.2.3(22ba0ed89dd6406cbb938bb1364fc5c5)
+ nuxt-site-config: 4.2.3(1fa5557b7a9b0a358862a4710025e777)
+ zod: 4.6.5
+ transitivePeerDependencies:
+ - confbox
+ - dotenv
+ - giget
+ - jiti
+ - magic-string
+ - mlly
+ - oxc-parser
+ - rolldown
+ - unplugin
+ - vite
+
+ nuxtseo-shared@5.3.16(be474f164f65517a791b10007c1d9f36):
+ dependencies:
+ '@clack/prompts': 1.8.1
+ '@nuxt/devtools-kit': 4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/schema': 4.6.0
+ birpc: 4.2.0
+ consola: 3.4.2
+ defu: 6.1.7
+ nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(better-sqlite3@12.11.1)(cac@6.7.14)(commander@14.0.3)(confbox@0.2.4)(db0@0.3.4(better-sqlite3@12.11.1))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.0))(rollup@4.64.0)(sass@1.105.1)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.12(typescript@5.9.3))(yaml@2.9.1)
+ nypm: 0.6.10
+ ofetch: 1.5.1
+ pathe: 2.0.3
+ pkg-types: 2.3.3
+ radix3: 1.1.2
+ sirv: 3.0.2
+ std-env: 4.3.0
+ ufo: 1.6.4
+ vue: 3.5.43(typescript@5.9.3)
+ optionalDependencies:
+ nuxt-site-config: 4.2.3(1fa5557b7a9b0a358862a4710025e777)
zod: 4.6.5
transitivePeerDependencies:
- confbox
@@ -15642,6 +15769,12 @@ snapshots:
exsolve: 1.1.3
pathe: 2.0.3
+ playwright-core@1.63.0: {}
+
+ playwright@1.63.0:
+ dependencies:
+ playwright-core: 1.63.0
+
possible-typed-array-names@1.1.0: {}
postcss-calc@10.1.1(postcss@8.5.29):
@@ -15835,10 +15968,6 @@ snapshots:
process@0.11.10: {}
- promise@7.3.1:
- dependencies:
- asap: 2.0.6
-
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
@@ -15921,6 +16050,8 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.2
+ proto-list@1.2.4: {}
+
protocols@2.0.2: {}
proxy-addr@2.0.8:
@@ -15935,73 +16066,6 @@ snapshots:
picocolors: 1.1.1
sade: 1.8.1
- pug-attrs@3.0.0:
- dependencies:
- constantinople: 4.0.1
- js-stringify: 1.0.2
- pug-runtime: 3.0.1
-
- pug-code-gen@3.0.4:
- dependencies:
- constantinople: 4.0.1
- doctypes: 1.1.0
- js-stringify: 1.0.2
- pug-attrs: 3.0.0
- pug-error: 2.1.0
- pug-runtime: 3.0.1
- void-elements: 3.1.0
- with: 7.0.2
-
- pug-error@2.1.0: {}
-
- pug-filters@4.0.0:
- dependencies:
- constantinople: 4.0.1
- jstransformer: 1.0.0
- pug-error: 2.1.0
- pug-walk: 2.0.0
- resolve: 1.22.12
-
- pug-lexer@5.0.1:
- dependencies:
- character-parser: 2.2.0
- is-expression: 4.0.0
- pug-error: 2.1.0
-
- pug-linker@4.0.0:
- dependencies:
- pug-error: 2.1.0
- pug-walk: 2.0.0
-
- pug-load@3.0.0:
- dependencies:
- object-assign: 4.1.1
- pug-walk: 2.0.0
-
- pug-parser@6.0.0:
- dependencies:
- pug-error: 2.1.0
- token-stream: 1.0.0
-
- pug-runtime@3.0.1: {}
-
- pug-strip-comments@2.0.0:
- dependencies:
- pug-error: 2.1.0
-
- pug-walk@2.0.0: {}
-
- pug@3.0.4:
- dependencies:
- pug-code-gen: 3.0.4
- pug-filters: 4.0.0
- pug-lexer: 5.0.1
- pug-linker: 4.0.0
- pug-load: 3.0.0
- pug-parser: 6.0.0
- pug-runtime: 3.0.1
- pug-strip-comments: 2.0.0
-
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
@@ -16084,14 +16148,6 @@ snapshots:
readdirp@5.1.1: {}
- recast@0.23.21:
- dependencies:
- ast-types: 0.16.3
- esprima: 4.0.1
- source-map: 0.6.1
- tiny-invariant: 1.3.3
- tslib: 2.8.1
-
redis-errors@1.2.0: {}
redis-parser@3.0.0:
@@ -16684,9 +16740,11 @@ snapshots:
picocolors: 1.1.1
sax: 1.6.1
- swrv@1.3.0(vue@3.5.43(typescript@5.9.3)):
+ swrv@1.3.0(@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)))(vue@3.5.43(typescript@5.9.3)):
dependencies:
vue: 3.5.43(typescript@5.9.3)
+ optionalDependencies:
+ '@vue/test-utils': 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))
tagged-tag@1.0.0: {}
@@ -16761,6 +16819,8 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@6.2.1: {}
+
tinyclip@0.1.15: {}
tinyexec@1.3.1: {}
@@ -16782,8 +16842,6 @@ snapshots:
toidentifier@1.0.1: {}
- token-stream@1.0.0: {}
-
tosource@2.0.0-alpha.3: {}
totalist@3.0.1: {}
@@ -16800,8 +16858,6 @@ snapshots:
dependencies:
typescript: 5.9.3
- ts-map@1.0.3: {}
-
tslib@2.8.1: {}
tunnel-agent@0.6.0:
@@ -16875,6 +16931,13 @@ snapshots:
rolldown: 1.2.12
unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ unctx@3.0.1(magic-string@1.4.3)(oxc-parser@0.141.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))):
+ optionalDependencies:
+ magic-string: 1.4.3
+ oxc-parser: 0.141.0
+ rolldown: 1.2.12
+ unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+
unctx@3.0.1(magic-string@1.4.3)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))):
optionalDependencies:
magic-string: 1.4.3
@@ -17001,7 +17064,7 @@ snapshots:
unpipe@1.0.0: {}
- unplugin-auto-import@21.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(@vueuse/core@14.4.0(vue@3.5.43(typescript@5.9.3)))(esbuild@0.28.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)):
+ unplugin-auto-import@21.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(@vueuse/core@14.4.0(vue@3.5.43(typescript@5.9.3)))(esbuild@0.28.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)):
dependencies:
local-pkg: 1.2.1
magic-string: 1.4.3
@@ -17010,7 +17073,7 @@ snapshots:
unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
unplugin-utils: 0.3.2
optionalDependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
'@vueuse/core': 14.4.0(vue@3.5.43(typescript@5.9.3))
transitivePeerDependencies:
- '@farmfe/core'
@@ -17051,7 +17114,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.7
- unplugin-vue-components@32.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)):
+ unplugin-vue-components@32.1.0(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))))(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)):
dependencies:
chokidar: 5.0.0
local-pkg: 1.2.1
@@ -17064,7 +17127,7 @@ snapshots:
unplugin-utils: 0.3.2
vue: 3.5.43(typescript@5.9.3)
optionalDependencies:
- '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
+ '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.3)(mlly@1.8.2)(oxc-parser@0.133.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))
transitivePeerDependencies:
- '@farmfe/core'
- '@rsbuild/core'
@@ -17305,7 +17368,27 @@ snapshots:
terser: 5.51.2
yaml: 2.9.1
- void-elements@3.1.0: {}
+ vitest@5.0.3(@types/node@26.6.4)(happy-dom@20.14.5)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/mocker': 5.0.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))
+ chai: 6.3.0
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 1.4.3
+ obug: 2.2.1
+ picomatch: 4.0.7
+ std-env: 4.3.0
+ tinybench: 6.2.1
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)
+ why-is-node-running: 3.2.1
+ optionalDependencies:
+ '@types/node': 26.6.4
+ happy-dom: 20.14.5
+ transitivePeerDependencies:
+ - msw
vscode-uri@3.2.0: {}
@@ -17329,22 +17412,6 @@ snapshots:
vue-devtools-stub@0.1.0: {}
- vue-docgen-api@4.79.2(vue@3.5.43(typescript@5.9.3)):
- dependencies:
- '@babel/parser': 7.29.9
- '@babel/types': 7.29.8
- '@vue/compiler-dom': 3.5.43
- '@vue/compiler-sfc': 3.5.43
- ast-types: 0.16.3
- esm-resolve: 1.0.11
- hash-sum: 2.0.0
- lru-cache: 8.0.5
- pug: 3.0.4
- recast: 0.23.21
- ts-map: 1.0.3
- vue: 3.5.43(typescript@5.9.3)
- vue-inbrowser-compiler-independent-utils: 4.71.1(vue@3.5.43(typescript@5.9.3))
-
vue-i18n@11.4.13(vue@3.5.43(typescript@5.9.3)):
dependencies:
'@intlify/core-base': 11.4.13
@@ -17353,10 +17420,6 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.43(typescript@5.9.3)
- vue-inbrowser-compiler-independent-utils@4.71.1(vue@3.5.43(typescript@5.9.3)):
- dependencies:
- vue: 3.5.43(typescript@5.9.3)
-
vue-router@5.3.1(@vue/compiler-sfc@3.5.43)(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.0)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))(vue@3.5.43(typescript@5.9.3)):
dependencies:
'@vue-macros/common': 3.1.4(vue@3.5.43(typescript@5.9.3))
@@ -17414,6 +17477,8 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
+ whatwg-mimetype@3.0.0: {}
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -17439,12 +17504,7 @@ snapshots:
dependencies:
isexe: 4.0.0
- with@7.0.2:
- dependencies:
- '@babel/parser': 7.29.9
- '@babel/types': 7.29.8
- assert-never: 1.4.0
- babel-walk: 3.0.0-canary-5
+ why-is-node-running@3.2.1: {}
word-wrap@1.2.5: {}
diff --git a/code/g3soft-libs/pnpm-workspace.yaml b/code/g3soft-libs/pnpm-workspace.yaml
index ca8ba999..573bd390 100644
--- a/code/g3soft-libs/pnpm-workspace.yaml
+++ b/code/g3soft-libs/pnpm-workspace.yaml
@@ -10,7 +10,7 @@ catalog:
# pnpm 11 的构建脚本白名单(map 形式;v10 的 onlyBuiltDependencies 数组写法在 v11 已失效)。
# better-sqlite3 —— Docus 全文搜索(FTS5)依赖的原生模块,必须放行才能下载/编译二进制
-# vue-demi —— vue-docgen-api 的传递依赖,安装时要跑脚本切换 Vue 版本 shim
+# vue-demi —— Vue 2/3 兼容 shim,部分工具链的传递依赖,安装时要跑脚本切换
# esbuild —— Nuxt / Vite 底层
# @parcel/watcher —— Docs(Nuxt) dev 的文件监听原生模块,放行以启用快速热更新。
# 注意:pnpm 11 会把「未放行的构建脚本」当作错误让后续命令失败,
diff --git a/code/g3soft-libs/scripts/gen-api.mjs b/code/g3soft-libs/scripts/gen-api.mjs
deleted file mode 100644
index 6a9e44a6..00000000
--- a/code/g3soft-libs/scripts/gen-api.mjs
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/usr/bin/env node
-/**
- * 从组件源码生成 API 数据。
- *
- * 用 vue-docgen-api 解析 packages/ui/src 下的 .vue 文件,
- * 提取 props / events / slots 及其 JSDoc 注释,输出到 docs/data/api/<组件名>.json,
- * 由文档站的 组件读取渲染。
- *
- * 这样 API 表格永远与源码一致,禁止手写。
- * 源码改动后执行:pnpm gen:api
- */
-import { mkdir, readdir, writeFile } from 'node:fs/promises'
-import { basename, dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { parse } from 'vue-docgen-api'
-
-const root = fileURLToPath(new URL('..', import.meta.url))
-const uiSrc = join(root, 'packages/ui/src')
-const outDir = join(root, 'docs/data/api')
-
-/** 递归收集所有 .vue 组件文件 */
-async function findComponents(dir) {
- const found = []
- for (const entry of await readdir(dir, { withFileTypes: true })) {
- const full = join(dir, entry.name)
- if (entry.isDirectory()) {
- found.push(...(await findComponents(full)))
- } else if (entry.name.endsWith('.vue')) {
- found.push(full)
- }
- }
- return found
-}
-
-/** packages/ui/src/button/Button.vue -> button */
-function toKey(file) {
- return basename(dirname(file))
-}
-
-/** docgen 给出的值可能带引号,统一剥掉 */
-function clean(text = '') {
- return String(text).replace(/^['"]|['"]$/g, '')
-}
-
-/** 把说明文字压成单行,避免表格里出现换行 */
-function oneLine(text = '') {
- return String(text).replace(/\s+/g, ' ').trim()
-}
-
-function mapMember(item) {
- return {
- name: item.name,
- description: oneLine(item.description),
- type: item.type?.name ?? '',
- defaultValue: item.defaultValue ? clean(item.defaultValue.value) : '',
- }
-}
-
-async function main() {
- const files = await findComponents(uiSrc)
- const results = []
-
- for (const file of files) {
- const info = await parse(file)
- const key = toKey(file)
-
- results.push([
- key,
- {
- displayName: info.displayName || key,
- description: oneLine(info.description),
- props: (info.props ?? []).map(mapMember),
- events: (info.events ?? []).map(mapMember),
- slots: (info.slots ?? []).map((slot) => ({
- name: slot.name,
- description: oneLine(slot.description),
- })),
- },
- ])
- }
-
- await mkdir(outDir, { recursive: true })
-
- for (const [key, doc] of results) {
- await writeFile(join(outDir, `${key}.json`), `${JSON.stringify(doc, null, 2)}\n`, 'utf-8')
- console.log(
- `✓ ${key.padEnd(12)} props=${doc.props.length} events=${doc.events.length} slots=${doc.slots.length}`,
- )
- }
-}
-
-main().catch((error) => {
- console.error('[gen-api] 失败:', error)
- process.exit(1)
-})
diff --git a/code/g3soft-libs/sel.yaml b/code/g3soft-libs/sel.yaml
new file mode 100644
index 00000000..a613854f
--- /dev/null
+++ b/code/g3soft-libs/sel.yaml
@@ -0,0 +1,2 @@
+- generic [ref=e3]:
+ - paragraph
\ No newline at end of file