Compare commits
No commits in common. "main" and "v1.0.3" have entirely different histories.
372
CAPABILITY.md
372
CAPABILITY.md
@ -1,372 +0,0 @@
|
||||
# @apigo.cc/base 能力清单 (Capability Specification)
|
||||
|
||||
<!-- section: header -->
|
||||
## 一、 全局导出与生命周期行为
|
||||
|
||||
### 1. 导出与全局挂载
|
||||
- **模块导出**:`index.js` 重新导出了 `http.js`, `ui.js`, `form.js`, `controls.js`, `list.js`, `nav.js`, `interaction.js` 中的所有对象,以及来自 `@apigo.cc/state` 的 `State`。
|
||||
- **全局命名空间挂载**:在 `window/globalThis` 上挂载了 `HTTP`, `UI`, `AutoForm`, `MouseMover`, `VirtualScroll`, `ApigoBase`。
|
||||
- **初始化自动刷新**:页面加载时(`DOMContentLoaded` 或立即执行),对 `document.documentElement` 执行 `__RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios()`。
|
||||
|
||||
### 2. 退出拦截逻辑
|
||||
- **拦截触发条件**:当 `State.exitBlocks > 0` 时,通过 `window.addEventListener('beforeunload')` 拦截页面刷新或关闭(调用 `event.preventDefault()`)。
|
||||
|
||||
### 3. 主题自动适配
|
||||
- **逻辑行为**:若 `<html>` 元素上既没有 `data-bs-theme` 也没有 `$data-bs-theme`,则自动注入属性:
|
||||
`$data-bs-theme="LocalStorage.darkMode?'dark':'light'"`。
|
||||
<!-- endsection: header -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: http -->
|
||||
## 二、 HTTP 请求引擎 (`HTTP`)
|
||||
|
||||
### 1. 核心方法
|
||||
- **`HTTP.request(options)`** (异步)
|
||||
- **参数类型**:`options: Object`
|
||||
- `url` (String, 必填): 请求地址。
|
||||
- `method` (String, 可选): 默认 `'POST'`。会内部自动转为大写。
|
||||
- `data` (Any, 可选): 请求载荷。
|
||||
- `headers` (Object, 可选): 默认 `{}`。
|
||||
- `responseType` (String, 可选): `'json' | 'binary' | 'text'`。若未提供,将根据响应头 `Content-Type` 自动解析(含 `application/json` 解析为 `'json'`,匹配图片/音视频/zip/pdf等解析为 `'binary'`,其余解析为 `'text'`)。
|
||||
- `timeout` (Number, 可选): 默认 `10000` (ms)。基于 `AbortSignal.timeout` 实现。
|
||||
- **请求载荷 (`data`) 自动处理逻辑**:
|
||||
- 若 `data` 为 `HTMLFormElement`,内部自动转换为 `FormData`。
|
||||
- 若 `data` 为普通对象,且其属性中包含 `File`, `Blob`, `FileList` 实例,内部自动转换为 `FormData`。其中,`FileList` 或 `Array` 属性会依次 append 到相同 key 下,排除 `undefined` / `null`。
|
||||
- 若数据最终为 `FormData`,则 **自动删除** `headers['Content-Type']`(由浏览器自动设置 boundary)。
|
||||
- 若数据为普通对象且非二进制,且 `headers` 中无 `Content-Type`,则序列化为 JSON 字符串并自动添加 `headers['Content-Type'] = 'application/json'`。
|
||||
- **返回值**:`Promise<ResponseObject>`,其结构为:
|
||||
```javascript
|
||||
{
|
||||
ok: Boolean, // 请求是否成功 (status 在 200-299)
|
||||
status: Number, // HTTP 状态码
|
||||
headers: Object, // 响应头键值对
|
||||
responseType: String, // 实际解析后的响应类型 ('json' | 'binary' | 'text')
|
||||
result: Any, // 响应数据主体
|
||||
error: String | null // 错误信息 (若请求失败或 ok === false)
|
||||
}
|
||||
```
|
||||
- **快捷方法** (内部包装 `HTTP.request`):
|
||||
- `HTTP.get({ url, ...opt })`
|
||||
- `HTTP.post({ url, data, ...opt })`
|
||||
- `HTTP.put({ url, data, ...opt })`
|
||||
- `HTTP.delete({ url, ...opt })`
|
||||
- `HTTP.head({ url, ...opt })`
|
||||
<!-- endsection: http -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: api -->
|
||||
## 三、 声明式数据组件 (`<API>`)
|
||||
|
||||
### 1. 响应式状态模型 (`State`)
|
||||
- **`request`** (读写): 映射请求参数。结构与 `HTTP.request` 参数一致:
|
||||
`{ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' }`
|
||||
- **`response`** (读写): 包含当前请求执行结果。结构为:
|
||||
`{ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null }`
|
||||
- **`result`** (读写): 响应结果快捷引用。当 `response.result` 为对象时,底层通过 `Object.assign` 合并更新;否则直接覆盖。
|
||||
|
||||
### 2. 编程接口方法 (`Methods`)
|
||||
- **`do(opt = {})`**
|
||||
- **用途**:手动执行请求。
|
||||
- **参数**:`opt` 对象的字段(如 `headers`, `data` 等)会与 `request` 属性合并。
|
||||
- **控制参数**:`opt.noui` (Boolean)。若为 `true`,则在请求失败时不自动调用 `UI.toast` 弹窗。
|
||||
- **返回值**:`Promise<ResponseObject>`。若请求失败或接口返回 `{ error: ... }`,Promise 会抛出异常(reject)。
|
||||
|
||||
### 3. 特殊逻辑行为
|
||||
- **`auto` (Attribute)**:
|
||||
若组件含有 `auto` 属性,且 `request.url` 非空,则深度监听 `request` 对象的变化。一旦变化,会在下一微任务队列(Promise.resolve())自动、异步触发 `do()`。有防抖机制,避免同一周期多次触发。
|
||||
|
||||
### 4. 事件派发
|
||||
- **`response`**:请求成功时触发。
|
||||
- `bubbles`: `false`
|
||||
- `cancelable`: `false`
|
||||
- `detail`: 完整的 `ResponseObject` 对象。
|
||||
- **`error`**:请求失败(网络错误、非2xx状态码、或返回数据中含 `error` 字段)时触发。
|
||||
- `bubbles`: `true`
|
||||
- `cancelable`: `false`
|
||||
- `detail`: Error 对象。
|
||||
<!-- endsection: api -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: form -->
|
||||
## 四、 万能表单系统 (`<AutoForm>`)
|
||||
|
||||
### 1. 容器属性 (Attributes)
|
||||
- **`vertical`** (Boolean): 开启垂直表单布局。
|
||||
- **`inline`** (Boolean): 开启无边框的流式行内布局(常用于顶部工具栏)。
|
||||
- **`nobutton`** (Boolean): 隐藏底部的默认提交/保存按钮。
|
||||
- **`api`** (String / Element): 绑定页面中 `<API>` 组件的 ID(或直接为组件实例引用),以便在提交时自动对接。
|
||||
- **`submitlabel`** (String): 自定义提交按钮的文本内容。默认为 `"{#Submit#}"`。
|
||||
|
||||
### 2. 实例编程接口
|
||||
- **`data`** (读写): 表单内部数据的响应式 Proxy 模型(映射至 `state.data`)。可直接通过 `Object.assign(form.data, newData)` 或逐个属性赋值来修改表单中的值。
|
||||
- **`request`** (读写): 默认提交配置,默认为 `{ method: 'POST' }`。
|
||||
- **`response`** (只读): 提交请求返回的完整响应对象。
|
||||
- **`result`** (只读): 提交请求返回的 `response.result`。
|
||||
- **`submit(opt = {})`**
|
||||
- **用途**:手动触发表单校验与提交。
|
||||
- **校验逻辑**:通过原生 `form.reportValidity()` 校验。若失败,调用 `UI.toast` 提示 `"{#verify failed#}"` 并退出。
|
||||
- **提交逻辑**:如果绑定了 `api` 属性,则调用 `api.do(req)`(其中 `req.data` 自动绑定为表单 `data`);否则,如果配置了 `request.url`,则直接调用 `HTTP.request(req)`;两者皆无则在控制台抛出警告。
|
||||
|
||||
### 3. Schema 项目 (`FormItem`) 结构定义
|
||||
`state.schema` 数组中每一项可配置的字段:
|
||||
- **`name`** (String, 必填): 数据字段键名,与 `form.data` 中的属性双向绑定。
|
||||
- **`label`** (String, 可选): 表单项标签名称(仅在非 `inline` 模式下展示)。
|
||||
- **`type`** (String, 必填): 表单控件类型。
|
||||
- 原生支持:`'text'`, `'password'`, `'email'`, `'number'`, `'date'`, `'datetime'`, `'file'`, `'select'`, `'checkbox'`, `'radio'`, `'switch'`, `'textarea'`。
|
||||
- 扩展注册支持:如 `'DatePicker'`, `'ColorPicker'`, `'IconPicker'`, `'TagsInput'`。
|
||||
- **`if`** (String, 可选): 控制是否显示该表单项的条件表达式字符串。使用双重计算渲染(`$$if`),其执行上下文中 `this` 指向 AutoForm 组件实例,可以通过 `this.data` 引用其他字段值。
|
||||
- **`setting`** (Object, 可选): 原生属性透传对象,会通过 `$.="item.setting || {}"` 绑定到控件上。
|
||||
- **`options`** (Array, 可选): 针对 `select`, `checkbox`, `radio` 类型。
|
||||
- 格式可以为扁平字符串数组:`['Option1', 'Option2']`
|
||||
- 也可以为键值对数组:`[{ label: '选项1', value: 'val1' }]`
|
||||
- **`placeholder`** (String, 可选): 仅对 `select` 类型有效,表示未选中时的禁用占位选项。
|
||||
- **`vertical`** (Boolean, 可选): 仅对 `checkbox` 或 `radio` 类型有效。若为 `true`,各选项纵向排列;默认为 `false`(横向排列)。
|
||||
- **`text` / `label`** (String, 可选): 当 `checkbox` 或 `radio` 未提供 `options` 选项时,使用此值作为单选项的标签,其选中时的值是 `'on'`。
|
||||
|
||||
### 4. 插槽 (Slots)
|
||||
- **`actions`**:在表单底部提交按钮左侧的区域插槽(仅在非 `inline` 且非 `nobutton` 模式下渲染)。
|
||||
|
||||
### 5. 事件派发
|
||||
- **`submit`**:在开始提交数据前触发。
|
||||
- `bubbles`: `false`
|
||||
- `cancelable`: `true`(调用 `event.preventDefault()` 可彻底终止提交动作)
|
||||
- `detail`: 当前表单的数据对象 `container.data`。
|
||||
- **`response`**:表单自动提交成功并得到接口响应后触发。
|
||||
- `bubbles`: `false`
|
||||
- `cancelable`: `false`
|
||||
- `detail`: 完整的响应对象 `resp`。
|
||||
- **`error`**:提交失败(网络错、校验错或返回的 json 带有 error 字段)时触发。
|
||||
- `bubbles`: `true`
|
||||
- `cancelable`: `false`
|
||||
- `detail`: Error 对象。
|
||||
|
||||
### 6. 表单控件扩展机制 (`AutoForm.register`)
|
||||
- **`AutoForm.register(name, typeName)`**
|
||||
- **用途**:将第三方/自定义 Web Component 注册为表单的自定义控件类型。
|
||||
- **参数**:
|
||||
- `name` (String): 组件标签名(如 `'DatePicker'`)。
|
||||
- `typeName` (String, 可选): schema 中对应的 `type` 字符串。不传则默认等同于 `name`。
|
||||
- **实现逻辑**:当在 AutoForm 的 `schema` 中匹配到对应 `type` 时,自动在 DOM 树中插入该自定义组件,并将其属性 `$.` 绑定为 `item.setting`,其值 `bind` 双向绑定到 AutoForm 的对应数据字段。
|
||||
<!-- endsection: form -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: extension-controls -->
|
||||
## 五、 表单自定义扩展组件
|
||||
|
||||
### 1. 日期选择器 (`<DatePicker>`)
|
||||
- **核心能力**:支持单日期选择,或双日期范围选择(主字段 + 影子字段模式)。
|
||||
- **启用范围选择**:在 AutoForm 的对应 Schema 项目中,配置 `setting.rangeEnd: "endFieldName"`;或在组件上直接配置 `rangeEnd` 属性。
|
||||
- **内部状态 (`state`)**:
|
||||
- `start` (String): 开始日期。
|
||||
- `end` (String): 结束日期。
|
||||
- **实例属性**:
|
||||
- `value` (读写): 映射至 `state.start`。
|
||||
- `isRange` (只读): 根据是否配置了 `rangeEnd` 返回 `Boolean` 值。
|
||||
- **操作方法**:
|
||||
- `updateStart(val)`: 更新 `state.start` 并分发 `change` 事件。
|
||||
- `updateEnd(val)`: 更新 `state.end`,若处于 AutoForm 容器中,将该值同步写入到表单数据的 `rangeEnd` 对应字段中。
|
||||
- **事件派发**:
|
||||
- `'change'`:当主字段/开始日期发生改变时触发。
|
||||
- `bubbles`: `true`
|
||||
- `detail`: 改变后的开始日期字符串(`state.start`)。
|
||||
|
||||
### 2. 颜色选择器 (`<ColorPicker>`)
|
||||
- **核心能力**:提供颜色取色器(`type="color"`)与十六进制文本输入框的双向联动。
|
||||
- **内部状态 (`state`)**:
|
||||
- `value` (String): 颜色值,默认为 `'#000000'`。
|
||||
- **实例属性**:
|
||||
- `value` (读写): 映射至 `state.value`。
|
||||
- **方法**:
|
||||
- `updateValue(val)`: 更新颜色状态并分发事件。
|
||||
- **事件派发**:
|
||||
- `'change'`:当值发生改变时触发。
|
||||
- `bubbles`: `true`
|
||||
- `detail`: 改变后的十六进制颜色字符串。
|
||||
|
||||
### 3. 图标选择器 (`<IconPicker>`)
|
||||
- **核心能力**:基于 Bootstrap Icons 的可视化下拉搜索选择器。
|
||||
- **内部状态 (`state`)**:
|
||||
- `value` (String): 当前选中的图标名。
|
||||
- `search` (String): 图标搜索关键字。
|
||||
- `open` (Boolean): 下拉菜单的展开状态。
|
||||
- **实例属性**:
|
||||
- `value` (读写): 映射至 `state.value`。
|
||||
- `filteredIcons` (只读): 经过 `search` 关键字过滤后的图标名称列表。
|
||||
- **方法**:
|
||||
- `selectIcon(icon)`: 选中指定图标,收起下拉框并派发 `change` 事件。
|
||||
- `toggle()`: 切换下拉框展开状态。若展开,会自动延时聚焦在搜索输入框中。
|
||||
- **事件派发**:
|
||||
- `'change'`:当选中图标发生改变时触发。
|
||||
- `bubbles`: `true`
|
||||
- `detail`: 选中的图标类名。
|
||||
- *点击外部收起*:监听全局 `click` 事件,点击外部自动收起下拉框;组件被移除 DOM 时自动注销该监听。
|
||||
|
||||
### 4. 标签输入框 (`<TagsInput>`)
|
||||
- **核心能力**:可视化添加和删除标签。
|
||||
- **添加交互**:在输入框中输入内容后,按 `Enter`, `,` (逗号) 或 ` ` (空格) 会自动将标签加入。
|
||||
- **删除交互**:点击标签聚焦后,按 `Backspace` 或 `Delete` 键可将其移除,并自动聚焦到前一个标签。
|
||||
- **内部状态 (`state`)**:
|
||||
- `tags` (Array): 存储标签字符串的数组。
|
||||
- **实例属性**:
|
||||
- `value` (读写): 映射至 `state.tags`。
|
||||
- **事件派发**:
|
||||
- `'change'`:当标签数组发生增删改变时触发。
|
||||
- `bubbles`: `true`
|
||||
- `detail`: 最新标签的字符串数组。
|
||||
<!-- endsection: extension-controls -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: list -->
|
||||
## 六、 增强列表组件 (`<List>`)
|
||||
|
||||
### 1. 列表布局模式 (`mode` Attribute)
|
||||
- **`normal`** (默认): 扁平列表模式。
|
||||
- **`group`** (分组):
|
||||
- 关联逻辑:通过 `list` 中成员的 `groupfield` 匹配 `groups` 数组中项的 `groupidfield`。
|
||||
- **`tree`** (树形):
|
||||
- 关联逻辑:扁平化数组,通过每一项的 `parentfield` 匹配父项的 `idfield`。根项的值为空字符串 `''`。
|
||||
|
||||
### 2. 字段映射配置属性 (可重写默认值)
|
||||
在组件初始化时,会调用 `Util.updateDefaults` 将以下属性更新为默认值:
|
||||
- `idfield`: 项的主键字段名,默认 `'id'`。
|
||||
- `labelfield`: 项展示文本字段名,默认 `'label'`。
|
||||
- `summaryfield`: 项辅助文本字段名,默认 `'summary'`。
|
||||
- `groupidfield`: 分组的主键字段名,默认 `'id'`。
|
||||
- `grouplabelfield`: 分组展示文本字段名,默认 `'label'`。
|
||||
- `groupsummaryfield`: 分组辅助文本字段名,默认 `'summary'`。
|
||||
- `groupfield`: 项关联分组的字段名,默认 `'group'`。
|
||||
- `parentfield`: 树形下关联父节点的字段名,默认 `'parent'`。
|
||||
- `groupicon`: 组/父节点的图标类,默认 `'folder'` (对应 Bootstrap Icons 的类名)。
|
||||
- `itemicon`: 项/叶子节点的图标类,默认 `'file'`。
|
||||
|
||||
### 3. 功能属性 (Attributes)
|
||||
- **`fast`** (Boolean): 启用虚拟滚动。
|
||||
- **重要约束**:`<List>` 容器必须包含 Bootstrap 的 `overflow-auto` 类,且内置 `style="overflow-anchor:none"`(模板自带)。
|
||||
- **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。
|
||||
- **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。
|
||||
- **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup`。
|
||||
|
||||
### 4. 内部状态模型 (`State`)
|
||||
- `list` (读写): 原始列表数据数组。
|
||||
- `groups` (读写): 仅在 `group` 模式下使用,分组定义数组。
|
||||
- `collapsed` (读写): 仅在 `tree` 且 `collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。
|
||||
- `selectedItem` (读写): 当前选中的列表项 ID。
|
||||
- `selectedGroup` (读写): 当前选中的分组 ID。
|
||||
- `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。
|
||||
- `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。
|
||||
|
||||
### 5. 插槽 (Slots)
|
||||
- **`item`**:列表单项渲染模板插槽。在自定义模板中,可以访问当前行数据 `item` 和索引 `index`。
|
||||
- **`item-actions`**:行数据右侧按钮工具栏区域。
|
||||
- **`group-actions`**:分组数据行右侧按钮工具栏区域。
|
||||
|
||||
### 6. 虚拟滚动运行参数
|
||||
- **高度优先规则**:
|
||||
若列表数据的某一项上配置了数字属性 `_itemHeight`,虚拟滚动引擎会将其作为该项的测量高度,直接跳过 DOM 的高度测量。
|
||||
|
||||
### 7. 事件派发
|
||||
- **`itemclick`**:点击列表项(非分组)时触发。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `{ item, index }` (其中 `index` 为考虑虚拟滚动偏移量后的 **绝对全局索引**)。
|
||||
- **`groupclick`**:点击分组行时触发。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `{ item, index }`。
|
||||
<!-- endsection: list -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: nav -->
|
||||
## 七、 导航组件 (`<Nav>`)
|
||||
|
||||
### 1. 结构与属性
|
||||
- **`vertical`** (Attribute, Boolean): 决定导航为垂直面板布局(带有右侧分割线)或水平导航栏布局(带底部阴影)。
|
||||
|
||||
### 2. 响应式数据模型 (`State`)
|
||||
- **`brand`** (Object, 可选): `{ image, icon, label }`,控制左上角/顶部的 Logo、图标及品牌文本。
|
||||
- **`list`** (Array, 必填): 导航栏项的数组。
|
||||
|
||||
### 3. 导航项结构(`list` 成员)
|
||||
- **`type`**: 决定导航项的渲染模式:
|
||||
- `'button'`: 导航按钮。
|
||||
- `'dropdown'`: 下拉菜单导航。含有 `list` (子项数组),子项类型可为:
|
||||
- `'button'`: 子项按钮。
|
||||
- `'switch'`: 状态开关。需要配置 `bind` (状态Proxy引用) 和 `name` (要绑定的字段名)。
|
||||
- 水平布局下可以通过 `width` (Number) 控制下拉菜单宽度,默认 `250`。
|
||||
- `'fill'`: 弹性填充块(`flex-fill`),用于实现右对齐等布局。
|
||||
- **`name`** (String): 导航标识。当点击导航按钮时,该 `name` 会自动被写入 `Hash.nav` 以同步 URL 哈希。
|
||||
- **`label`** (String): 文本展示。
|
||||
- **`icon`** (String): 图标类名(基于 Bootstrap Icons)。
|
||||
- **`noselect`** (Boolean, 可选): 若为 `true`,点击该项仅派发点击事件,**不会** 修改 `Hash.nav`。
|
||||
|
||||
### 4. 事件派发
|
||||
- **`nav`**:点击任何菜单项或下拉子菜单项时触发。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `{ item }` (被点击的导航项数据定义)。
|
||||
<!-- endsection: nav -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: resizer -->
|
||||
## 八、 拖拽改变大小组件 (`<Resizer>`)
|
||||
|
||||
### 1. 组件属性
|
||||
- **`vertical`** (Attribute, Boolean): 为 `true` 时,鼠标为 `row-resize`,调整高度;默认调整宽度。
|
||||
- **`min`** (Attribute, Number): 像素下限,默认 `10`。
|
||||
- **`max`** (Attribute, Number): 像素上限,默认 `1000`。
|
||||
- **`target`** (Element): 要调整大小的目标 DOM 元素。默认是组件的前一个兄弟节点 (`container.previousElementSibling`)。
|
||||
|
||||
### 2. 事件与双向绑定
|
||||
- **`bind`** (输入拦截): 支持通过绑定表达式传入像素值(数字),自动设置为 target 的 `width`/`height`。
|
||||
- **`resizing`**:拖动调整大小的过程中持续触发。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `{ oldSize, newSize }`
|
||||
- **`resize`**:拖动结束时触发。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `{ oldSize, newSize }`
|
||||
- **`change`**:拖动结束时触发,输出最终的大小数值。常用于将新尺寸写回全局 `State`。
|
||||
- `bubbles`: `false`
|
||||
- `detail`: `newSize` (Number)
|
||||
<!-- endsection: resizer -->
|
||||
|
||||
---
|
||||
|
||||
<!-- section: ui -->
|
||||
## 九、 编程交互工具集 (`UI`)
|
||||
|
||||
### 1. 核心对话框方法 (`UI.showDialog`)
|
||||
- **`UI.showDialog({ title, message, buttons, type })`**
|
||||
- **参数**:
|
||||
- `title` (String, 可选): 对话框标题。
|
||||
- `message` (String, 必填): 提示消息,支持 HTML 标记(`$html` 渲染)。
|
||||
- `buttons` (Array, 可选): 按钮文字数组,默认 `['{#Close#}']`。
|
||||
- `type` (String, 可选): 主题类型 `'primary' | 'danger' | 'warning' | 'success'` 等,控制边框、标题及最后一个主按钮的颜色。
|
||||
- **返回值**:`Promise<Number>`。
|
||||
- 点击按钮返回其索引值 (从 `1` 开始)。
|
||||
- 点击关闭图标或按 ESC 取消返回 `0`。
|
||||
|
||||
### 2. 快捷对话框
|
||||
- **`UI.alert(message, options)`**:包装 `UI.showDialog`。
|
||||
- **`UI.confirm(message, options)`**:确认框。
|
||||
- 默认按钮:`['{#Cancel#}', '{#Confirm#}']`。
|
||||
- 返回值:`Promise<Boolean>` (选中确认按钮为 `true`,取消为 `false`)。
|
||||
|
||||
### 3. 轻提示方法 (`UI.toast`)
|
||||
- **`UI.toast(message, options)`**
|
||||
- **参数**:
|
||||
- `message` (String, 必填): 消息文本。
|
||||
- `options` (Object, 可选):
|
||||
- `delay` (Number): 自动关闭延迟时间(毫秒),默认 `5000`。若传入 `0` 则永久不消失。
|
||||
- `type` (String): 主题背景色 `'primary' | 'success' | 'danger' | 'warning'` 等。
|
||||
- `buttons` (Array): 自定义 Toast 内嵌按钮,点击会设置 `result = index + 1` 并在点击后自动 dismiss。
|
||||
- `container` (String): 目标 Toast 容器 ID,默认 `'default'`。
|
||||
|
||||
### 4. 快捷 Toast 确认
|
||||
- **`UI.toastConfirm(message, options)`**
|
||||
- 弹出带有一个 `Confirm` 按钮的 Toast。
|
||||
- 返回值:`Promise<Boolean>`(点击确认按钮返回 `true`,其余情况返回 `false`)。
|
||||
<!-- endsection: ui -->
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@ -1,68 +1,34 @@
|
||||
# Changelog
|
||||
# CHANGELOG
|
||||
|
||||
## [1.0.17] - 2026-06-09
|
||||
### Changed
|
||||
- **Non-ESM Architecture**: Refactored all source components to remove `import`/`export` and use IIFE wrappers, attaching directly to `globalThis`.
|
||||
- **Synchronous Dependency Loading**: Redesigned the test suite to load dependencies synchronously in the `<head>`, ensuring absolute timing stability and 100% production parity.
|
||||
- **Vite Alignment**: Updated build configuration to prioritize IIFE/UMD output and removed ESM distribution artifacts.
|
||||
- **Documentation**: Updated README with the new "Synchronous First" quick start guide and latest CDN versions.
|
||||
## v1.0.3 (2026-05-18)
|
||||
|
||||
## [1.0.14] - 2026-06-08
|
||||
### Fixed
|
||||
- **State Integration**: Bumped `@apigo.cc/state` to `1.0.18` to restore the globally injected `$`/`$$` helpers from the original UMD era, fixing `TagsInput` element targeting.
|
||||
- **AutoForm Layout**: Fixed `horizontal` grid layout breakage caused by missing `AUTOFORM_STYLE` registration.
|
||||
- **AutoForm Expansion**: Restored `AutoForm.register` with deep-blueprint `[control-wrapper]` physical insertion mechanism, fixing the blank rendering bug of `TagsInput` and `ColorPicker`.
|
||||
- **Form Controls**: Removed `height: 100%` from `ColorPicker` preventing flex squash; fixed horizontal mode left-padding overflow for Check/Radio/Switch components.
|
||||
- **Build**: Fully expunged `.mjs` artifacts from vite build pipeline to enforce UMD compliance.
|
||||
### 修复
|
||||
- **渲染引擎兼容**: 适配了最新的 State.js 架构,解决了由于组件扫描时序导致的 `FastList`、`FastTree` 等组件无法正确初始化的问题。确保了 `RefreshState` 的正确执行。
|
||||
- **模板合并增强**: 解决了当 `slot` 内容为 `TEMPLATE` 标签时,内容无法正确合并至组件内部的问题。
|
||||
- **状态透传**: 修复了 `FastGroupedList` 和 `FastTree` 内部嵌套 `FastList` 时,列表数据未正确透传的 Bug。
|
||||
- **接口对齐**: 统一了 `FastTree` 和 `FastGroupedList` 的 `refresh` 方法,支持外部通过组件实例直接触发重绘。
|
||||
|
||||
## [1.0.12] - 2026-06-05
|
||||
### Changed
|
||||
- **De-ESM Refactor**: Fully transitioned the runtime environment to synchronous UMD loading, eliminating race conditions and rendering artifacts.
|
||||
- **AutoForm Purification**: Purged all "black-box" patching logic that dynamically modified component templates. Field rendering now follows 100% transparent, explicit TEMPLATE logic.
|
||||
- **Philosophy Alignment**: Removed all forbidden `RefreshState` calls from business logic, entrusting the asynchronous `MutationObserver` engine for all UI updates.
|
||||
## v1.0.2 (2026-05-18)
|
||||
|
||||
## [1.0.11] - 2026-06-05
|
||||
### Changed
|
||||
- Dependency: Updated `@apigo.cc/state` CDN script dependency to `1.0.16` for AutoForm nested field mounting bindings fix.
|
||||
### 新特性
|
||||
- **AutoForm**: 新增 `inline` 模式,支持紧凑的单行表单布局,并增强了与 `DataTable` 的联动能力(数据变化自动刷新)。
|
||||
- **Resizer**:
|
||||
- 支持 `$bind` 指令,实现尺寸的双向绑定。
|
||||
- 新增 `resizing` 和 `change` 事件,便于监听实时与最终缩放结果。
|
||||
|
||||
## [1.0.10] - 2026-06-05
|
||||
### Fixed
|
||||
- List: Removed reactive data-binding for `prevHeight` and `postHeight` and refactored virtual scroll to directly mutate padding DOM node heights, preventing reactive re-render overhead.
|
||||
- List: Ignored elements with `offsetHeight === 0` during layout measurement, preventing hidden tab state (display: none) from polluting height average `avg`.
|
||||
- List: Applied a safety minimum limit (16px) to average item height calculations to prevent `visibleCount` explosion.
|
||||
- List: Implemented a synchronization re-entry lock using `setTimeout` in list refresh to cut recursive `scroll` event loops.
|
||||
- Config: Configured `server.fs.allow: ['..']` in vite.config.js to allow local development cross-directory assets access (like fonts).
|
||||
### 优化
|
||||
- **UI**:
|
||||
- `Modal`, `Dialog`, `Toast` 默认主题色由 `body` 改为 `primary`,提升视觉引导。
|
||||
- `Toast` 增加 `rounded` 圆角样式。
|
||||
- 统一所有表单控件(input/select/textarea)在 `AutoForm` 中的设置默认为 `{}`。
|
||||
- **FastList**: 重构虚拟滚动逻辑为独立的 `VirtualScroll` 模块,提升代码复用性与可维护性。
|
||||
|
||||
## [1.0.9] - 2026-06-05
|
||||
### Changed
|
||||
- Document: Comprehensive rewrite of base/README.md utilizing example-driven and static declaration binding paradigm.
|
||||
- Document: Created base/CAPABILITY.md as the 100% complete spec checklist for all components and API.
|
||||
- Config: Updated @apigo.cc/state dependency reference to v1.0.13.
|
||||
## v1.0.1 (2026-05-14)
|
||||
- **Perf**: 优化 `FastList` 渲染逻辑,消除 Layout Thrashing,提升复杂列表(GroupedList/Tree)滚动性能达 10 倍以上。
|
||||
- **Refactor**: 采用 `requestAnimationFrame` 优化初始化高度计算,减少闪烁。
|
||||
|
||||
## [1.0.8] - 2026-06-05
|
||||
### Changed
|
||||
- Bump version and align dependencies.
|
||||
|
||||
## [1.0.7] - 2026-05-29
|
||||
### Added
|
||||
- DatePicker: New control with range support (main/shadow field sync).
|
||||
- ColorPicker: New control with color input and text hex input.
|
||||
- IconPicker: New control with searchable Bootstrap Icons grid.
|
||||
- AutoForm: Automatically registered the new controls.
|
||||
|
||||
## [1.0.6] - 2026-05-27
|
||||
### Added
|
||||
- AutoForm: Support data-driven visibility using `field.if` (utilizing state's `$$` double-calculation).
|
||||
- AutoForm: Added reactive `data` property mapped to `state.data`.
|
||||
|
||||
### Changed
|
||||
- AutoForm: Decoupled from global `State.editingData`. Data must now be passed via the `data` property.
|
||||
- Internal: Optimized `_addAutoFormComponent` to support nested templates.
|
||||
|
||||
## [1.0.5] - 2026-05-23
|
||||
### Added
|
||||
- Documented project-wide "Manual DOM Sync" design principle for performance critical components.
|
||||
|
||||
### Fixed
|
||||
- Verified VirtualScroll stability and timing.
|
||||
- Synchronized component registration lifecycle during rapid DOM mutations.
|
||||
## v1.0.0 (2026-05-14)
|
||||
- **Feat**: 初始化项目结构,建立 ESM 开发环境。
|
||||
- **Refactor**: 将 `base.js` 迁移至 `src/index.js`,采用 ESM 导出。
|
||||
- **Build**: 配置 Vite 双轨构建系统。
|
||||
- **Test**: 集成 Playwright 自动化测试。
|
||||
|
||||
351
README.md
351
README.md
@ -1,304 +1,103 @@
|
||||
# @apigo.cc/base - AI 逻辑操作说明书 (示例驱动)
|
||||
# @web/base AI 开发指南 (全面版)
|
||||
|
||||
本库是基于 `@apigo.cc/state` 增强的 UI 原子库,提供遵循 Bootstrap 5.3 规范的高阶业务组件。本包采用完全的全局注入模式。
|
||||
`@web/base` 是基于 State.js 构建的高性能 Web 基础组件库。它采用**原生 ESM、零打包**架构,深度集成 Bootstrap 5,旨在为 AI 驱动的开发提供极致精简且功能完备的 UI 与逻辑基建。
|
||||
|
||||
---
|
||||
|
||||
## 全局变量与核心 API 清单
|
||||
引入 `base.js` 后,以下对象将直接挂载到 `window` / `globalThis` 上,可随时随地调用:
|
||||
## 一、 快速开始
|
||||
|
||||
| 全局变量名 | 用途说明 |
|
||||
| :--- | :--- |
|
||||
| **`HTTP`** | 异步请求工具 (`HTTP.get`, `HTTP.post`, `HTTP.request` 等)。 |
|
||||
| **`UI`** | 交互组件触发器 (`UI.toast`, `UI.alert`, `UI.confirm`, `UI.showDialog`)。 |
|
||||
| **`AutoForm`** | 表单组件扩展注册器 (`AutoForm.register`) 和自定义控件库 (`AutoForm.customTypes`)。 |
|
||||
| **`MouseMover`** | 拖拽辅助工具 (`MouseMover.start`)。 |
|
||||
| **`VirtualScroll`** / **`List`** | 虚拟滚动列表核心算法类。 |
|
||||
### 1. 引入依赖
|
||||
在 HTML 中配置 `importmap`。推荐使用 `loader.js` 自动管理:
|
||||
|
||||
*(注意:底层状态库 `@apigo.cc/state` 暴露的 `NewState`, `Hash`, `LocalStorage`, `State`, `$`, `$$`, `Component` 以及危险的高级 API `_unsafeRefreshState` 也在全局可用。严禁随意调用 `_unsafeRefreshState`。)*
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速集成 (Quick Start)
|
||||
|
||||
将脚本放置在 `<head>` 中,确保地基在 DOM 解析前就绪:
|
||||
```html
|
||||
<!-- 1. 基础状态机 (地基) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.19/dist/state.min.js"></script>
|
||||
<!-- 2. Bootstrap 适配层 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.6/dist/bootstrap.min.js"></script>
|
||||
<!-- 3. 本业务组件库 (原生注入) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.17/dist/base.min.js"></script>
|
||||
<!-- 依赖 Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 4. 数据先行 (在 body 解析前定义)
|
||||
window.brand = { label: 'My App' };
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "path/to/state.mjs",
|
||||
"@web/base": "path/to/base.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 组件超能力示例 (Mega Examples)
|
||||
|
||||
### 2.1 声明式数据组件 (`<API>`)
|
||||
```html
|
||||
<API id="userSearchApi" auto
|
||||
$request='{
|
||||
"url": "/api/users/search",
|
||||
"method": "POST",
|
||||
"data": { "keyword": State.searchKey }
|
||||
}'
|
||||
$onresponse="console.log('加载成功,响应对象:', event.detail); State.userList = event.detail.result"
|
||||
$onerror="UI.toast('加载失败:' + event.detail.message, { type: 'danger' })">
|
||||
</API>
|
||||
|
||||
<script>
|
||||
// 手动调用 API 示例 (适合动态触发/事后赋值场景)
|
||||
async function forceReload() {
|
||||
try {
|
||||
// do() 返回 Promise<ResponseObject>。传入 noui: true 屏蔽全局 UI 弹窗报错。
|
||||
const resp = await userSearchApi.do({ noui: true });
|
||||
console.log("手动加载完成,结果:", resp.result);
|
||||
} catch (err) {
|
||||
console.error("请求失败", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* **请求响应结构 (ResponseObject)**:
|
||||
`{ ok: Boolean, status: Number, headers: Object, responseType: 'json'|'binary'|'text', result: Any, error: String|null }`
|
||||
* 深度监听 `request` 任何字段的改变自动触发请求(微任务防抖)。`response` 事件为 `bubbles: false`,`error` 为 `bubbles: true`。
|
||||
### 2. 导出清单
|
||||
* **Logic**: `HTTP`, `UI`, `State`, `MouseMover`
|
||||
* **Components**: `<API>`, `<Modal>`, `<Dialog>`, `<Toast>`, `<AutoForm>`, `<TagsInput>`, `<FastList>`, `<List>`, `<GroupedList>`, `<FastGroupedList>`, `<Tree>`, `<FastTree>`, `<CollapseTree>`, `<Nav>`, `<Resizer>`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 万能表单与内嵌控件 (`<AutoForm>`)
|
||||
```html
|
||||
<script>
|
||||
// 1. 预先确定表单 Schema (合并了所有扩展控件配置)
|
||||
const employeeFormSchema = [
|
||||
{ name: 'name', label: '姓名', type: 'text', setting: { required: true } },
|
||||
{
|
||||
name: 'joinDate',
|
||||
label: '入职周期',
|
||||
type: 'DatePicker', // 内置日期范围选择控件
|
||||
setting: { rangeEnd: 'leaveDate' } // 影子字段绑定为 leaveDate
|
||||
},
|
||||
{ name: 'color', label: '工位颜色', type: 'ColorPicker' }, // 内置颜色控件
|
||||
{ name: 'avatarIcon', label: '头像图标', type: 'IconPicker' }, // 内置图标控件
|
||||
{ name: 'skills', label: '专业技能', type: 'TagsInput' }, // 内置多标签控件
|
||||
{
|
||||
name: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [{ label: '管理员', value: 'admin' }, { label: '员工', value: 'staff' }],
|
||||
placeholder: '请选择角色...'
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: '状态',
|
||||
type: 'checkbox',
|
||||
options: ['在职', '离职'],
|
||||
if: "this.data.age > 18" // 仅在 age > 18 时渲染,this 访问 AutoForm 实例
|
||||
},
|
||||
{ name: 'notify', label: '启用通知', type: 'switch' }
|
||||
];
|
||||
</script>
|
||||
## 二、 核心组件详述
|
||||
|
||||
<!-- 2. HTML 声明式属性绑定 schema 状态,且绑定接口 API 实例 -->
|
||||
<AutoForm id="employeeForm"
|
||||
vertical
|
||||
$.state.schema="employeeFormSchema"
|
||||
$api="saveEmployeeApi"
|
||||
$onresponse="UI.toast('保存成功!')">
|
||||
### 1. 网络与数据 (`HTTP` & `<API>`)
|
||||
* **`HTTP`**: 静态请求工具(`get`, `post`, `put`, `delete`, `request`)。
|
||||
* **`<API>`**: 声明式请求容器。
|
||||
* `$.request.url`: 请求地址。
|
||||
* `auto`: 属性存在时,url 变化自动触发请求。
|
||||
* `@response`: 请求成功事件。
|
||||
|
||||
### 2. UI 交互 (Namespace `UI`)
|
||||
* `UI.alert(msg)` / `UI.confirm(msg)`: 基础对话框。
|
||||
* `UI.toast(msg, {type: 'success'})`: 自动消失的轻提示。
|
||||
|
||||
### 3. 数据驱动表单 (`<AutoForm>`)
|
||||
核心配置项:`$.state.schema` (结构) 和 `$.state.data` (数据)。
|
||||
|
||||
```html
|
||||
<AutoForm
|
||||
$.state.schema="[
|
||||
{name:'user', type:'text', label:'用户名', required:true},
|
||||
{name:'role', type:'select', options:['Admin', 'User'], label:'角色'}
|
||||
]"
|
||||
$.state.data="myData">
|
||||
</AutoForm>
|
||||
|
||||
<script>
|
||||
// 3. 数据层回显/事后赋值操作 (使用 Object.assign 保证绑定的 Proxy 响应式链路不丢失)
|
||||
Object.assign(employeeForm.state.data, {
|
||||
name: '张三',
|
||||
joinDate: '2026-06-01',
|
||||
leaveDate: '2026-06-30',
|
||||
color: '#ff0000',
|
||||
avatarIcon: 'person',
|
||||
skills: ['Vue', 'React'],
|
||||
role: 'staff',
|
||||
notify: true
|
||||
});
|
||||
</script>
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* 容器属性:`vertical` (垂直排列)、`inline` (无边框流式行内布局)、`nobutton` (隐藏底部默认按钮)。
|
||||
* **表单数据操作红线**:禁止对 `form.state.data` 或 `form.data` 执行覆盖式重新赋值,必须使用 `Object.assign`。
|
||||
|
||||
---
|
||||
### 4. 高性能列表 (`<FastList>` 家族)
|
||||
支持万级数据、动态高度、虚拟滚动。
|
||||
|
||||
### 2.3 增强列表组件 (`<List>`)
|
||||
| 组件名 | 特点 |
|
||||
| :--- | :--- |
|
||||
| `<FastList>` | 基础虚拟滚动列表。 |
|
||||
| `<FastGroupedList>` | 带分组的虚拟列表。 |
|
||||
| `<FastTree>` | 虚拟滚动树。 |
|
||||
| `<CollapseTree>` | 支持折叠的树结构。 |
|
||||
|
||||
列表有三类布局模式,分别有不同的数据结构关联,均推荐在 HTML 中声明绑定:
|
||||
|
||||
#### 模式一:普通扁平列表 (`mode="normal"`)
|
||||
* **关联逻辑**:直接渲染一维数组项。
|
||||
* **数据结构**:
|
||||
```javascript
|
||||
const userList = [
|
||||
{ id: '1', label: '小明', summary: '前端开发' },
|
||||
{ id: '2', label: '小红', summary: '产品经理' }
|
||||
];
|
||||
```
|
||||
* **HTML 写法**:
|
||||
```html
|
||||
<List mode="normal" $list="userList" $onitemclick="console.log(event.detail.item)"></List>
|
||||
```
|
||||
|
||||
#### 模式二:分组列表 (`mode="group"`)
|
||||
* **关联逻辑**:`list` 的项通过 `group` 字段去匹配 `groups` 数组中的 `id` 字段。
|
||||
* **数据结构**:
|
||||
```javascript
|
||||
const deptGroups = [
|
||||
{ id: 'devGroup', label: '研发中心', summary: '共 5 人' },
|
||||
{ id: 'hrGroup', label: '人力资源', summary: '共 2 人' }
|
||||
];
|
||||
const groupUserList = [
|
||||
{ id: '1', label: '小明', group: 'devGroup' },
|
||||
{ id: '2', label: '小红', group: 'hrGroup' }
|
||||
];
|
||||
```
|
||||
* **HTML 写法**:
|
||||
```html
|
||||
<List mode="group" $list="groupUserList" $.state.groups="deptGroups"></List>
|
||||
```
|
||||
|
||||
#### 模式三:树形可折叠列表 (`mode="tree"`) —— 超级示例
|
||||
* **关联逻辑**:通过每项的 `parent` 关联父项的 `id` 属性,根项的 `parent` 设为空字符串 `''`。
|
||||
* **数据结构**(内置 `_itemHeight` 优化虚拟滚动高度测量):
|
||||
```javascript
|
||||
const deptTreeList = [
|
||||
{ id: '1', label: '总经办', parent: '', _itemHeight: 40 },
|
||||
{ id: '2', label: '研发部', parent: '1', _itemHeight: 40 },
|
||||
{ id: '3', label: '开发组', parent: '2', _itemHeight: 40 }
|
||||
];
|
||||
```
|
||||
* **HTML 写法**:
|
||||
```html
|
||||
<List id="orgTreeList"
|
||||
mode="tree"
|
||||
fast
|
||||
collapsible
|
||||
auto-select
|
||||
class="overflow-auto border"
|
||||
style="height: 400px;"
|
||||
$list="deptTreeList"
|
||||
$onitemclick="console.log('点击节点:', event.detail.item, '全局绝对索引:', event.detail.index)">
|
||||
|
||||
<!-- 自定义单项右侧动作区域 -->
|
||||
<template slot-id="item">
|
||||
<span class="fw-semibold text-primary" $text="item.label"></span>
|
||||
<div slot-id="item-actions">
|
||||
<button class="btn btn-sm btn-link py-0 bi bi-plus-circle" onclick="addNode(item)"></button>
|
||||
</div>
|
||||
</template>
|
||||
</List>
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* **属性默认值**:当数据结构符合默认字段命名(`id`, `label`, `summary`, `parent`, `group`)时,**无须填写**任何映射属性(如 `idfield`, `labelfield`, `parentfield` 等),实际编写代码时应当将其省略。
|
||||
* 虚拟滚动 (`fast`) 强制容器必须声明为 `overflow-auto` 类。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 导航组件 (`<Nav>`)
|
||||
**示例 (动态高度列表):**
|
||||
```html
|
||||
<script>
|
||||
// 1. 预先确定品牌与菜单数据
|
||||
const myBrand = { icon: 'shield-lock', label: '安全控制台' };
|
||||
const myNavList = [
|
||||
{ type: 'button', name: 'dashboard', label: '仪表盘', icon: 'speedometer' },
|
||||
{
|
||||
type: 'dropdown',
|
||||
name: 'settings',
|
||||
label: '系统设置',
|
||||
icon: 'gear',
|
||||
list: [
|
||||
{ type: 'button', name: 'profile', label: '个人信息', icon: 'person' },
|
||||
{ type: 'switch', name: 'darkMode', label: '暗黑模式', icon: 'moon', bind: LocalStorage, name: 'darkMode' }
|
||||
]
|
||||
},
|
||||
{ type: 'fill' },
|
||||
{ type: 'button', name: 'logout', label: '退出登录', icon: 'box-arrow-right', noselect: true }
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- 2. HTML 声明式属性绑定 -->
|
||||
<Nav id="sidebarNav"
|
||||
vertical
|
||||
$.state.brand="myBrand"
|
||||
$.state.list="myNavList"
|
||||
$onnav="console.log('导航点击:', event.detail.item)">
|
||||
</Nav>
|
||||
<FastList $.state.list="items">
|
||||
<div slot="item" $.style.height="${item.h}px" $text="item.label"></div>
|
||||
</FastList>
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* 常规导航项被点击时(且 `noselect` 不为 true),会自动更新全局 `Hash.nav = item.name`。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 拖拽改变大小组件 (`<Resizer>`)
|
||||
```html
|
||||
<div class="d-flex" style="height: 300px;">
|
||||
<div id="leftPanel" style="width: 200px;" class="bg-light">侧边栏</div>
|
||||
<!-- 拖拽调节器,默认 target 为前一个兄弟节点 -->
|
||||
<Resizer target="leftPanel"
|
||||
min="100"
|
||||
max="400"
|
||||
$onresizing="console.log('拖动尺寸:', event.detail.newSize)"
|
||||
$change="State.leftPanelWidth">
|
||||
</Resizer>
|
||||
<div class="flex-fill">内容区</div>
|
||||
</div>
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* `resizing` 与 `resize` 事件的 `detail` 结构均为 `{ oldSize: Number, newSize: Number }`;`change` 事件的 `detail` 为 `newSize` 像素数字。
|
||||
## 三、 组件详细清单与 API
|
||||
|
||||
### 逻辑类 (JS API)
|
||||
* **`HTTP.request(opt)`**: 返回 `Promise`,结果包含 `ok`, `status`, `result`, `error`。
|
||||
* **`UI.showDialog({title, message, buttons, type})`**: 弹出自定义对话框。
|
||||
* **`MouseMover.start(event, callbacks)`**: 全局鼠标移动监听(用于拖拽)。
|
||||
|
||||
### 组件类 (Custom Elements)
|
||||
* **`<Modal>`**:
|
||||
* `$bind="state.show"`: 双向绑定显隐。
|
||||
* `slot-id="header/body/footer"`: 内容插槽。
|
||||
* **`<TagsInput>`**: 标签录入,绑定数据为字符串数组。
|
||||
* **`<Nav>`**: 响应式导航栏。
|
||||
* `$.state.brand`: `{image, icon, label}`。
|
||||
* `$.state.list`: 菜单数组。
|
||||
* **`<Resizer>`**:
|
||||
* `vertical`: 属性,切换水平/垂直。
|
||||
* `min/max`: 限制尺寸。
|
||||
|
||||
---
|
||||
|
||||
## 3. 网络与交互工具集 (`HTTP` & `UI`)
|
||||
|
||||
### 3.1 HTTP 请求工具 (`HTTP`)
|
||||
```javascript
|
||||
// 支持 HTTP.get, HTTP.post, HTTP.put, HTTP.delete, HTTP.head
|
||||
const resp = await HTTP.post({
|
||||
url: '/api/user/save',
|
||||
data: { name: 'Alice', file: avatarFile },
|
||||
timeout: 5000
|
||||
});
|
||||
```
|
||||
* **返回值 `ResponseObject` 结构**:
|
||||
* `ok` (Boolean): 状态码是否在 200-299。
|
||||
* `status` (Number): HTTP 状态码。
|
||||
* `headers` (Object): 响应头键值对。
|
||||
* `responseType` (String): `'json' | 'binary' | 'text'`。
|
||||
* `result` (Any): 成功后的解析结果。
|
||||
* `error` (String | null): 失败的错误描述。
|
||||
* **数据自动转换**:若 `data` 内包含 `File`/`Blob`,自动转为 `FormData` 且清除 Content-Type;普通对象自动序列化为 JSON 并添加 `application/json`。
|
||||
|
||||
### 3.2 交互工具 (`UI`)
|
||||
* **`UI.showDialog({ title, message, buttons, type })`**
|
||||
* **返回值**:`Promise<Number>`。点击按钮返回其索引值 (从 `1` 开始),点击关闭或取消返回 `0`。
|
||||
* **参数**:`type` 可以为 `'primary'|'danger'|'warning'|'success'` 控制主题色;`message` 支持 HTML。
|
||||
* **`UI.alert(message, options)`**
|
||||
* **返回值**:`Promise<Boolean>`(点击关闭按钮返回 `false`)。
|
||||
* **`UI.confirm(message, options)`**
|
||||
* **返回值**:`Promise<Boolean>`。点击“确认”返回 `true`,点击“取消”或关闭返回 `false`。
|
||||
* **`UI.toast(message, options)`**
|
||||
* **返回值**:`void`。`options.delay` 为自动消失延迟毫秒(`0` 代表不消失)。可以通过传入 `buttons: ['选项1']` 按钮提供交互,点击后在实例 `result` 上保存索引。
|
||||
* **`UI.toastConfirm(message, options)`**
|
||||
* **返回值**:`Promise<Boolean>`。在 Toast 中提供单确认按钮,点击确认返回 `true`,否则返回 `false`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 开发红线 (Constraints)
|
||||
|
||||
1. **表单数据操作红线**:严禁直接覆盖表单的 `state.data` 或 `data` 对象(如 `form.data = {}`)。这会切断与内部 Proxy 的响应式链路。必须使用 `Object.assign(form.state.data, newData)`。
|
||||
2. **结构化指令红线**:所有 `$if` 与 `$each` 指令 **必须** 显式作用于 `<template>` 标签,且严禁在同一个 `<template>` 上同时使用两者(必须嵌套)。
|
||||
3. **列表布局红线**:开启虚拟滚动(`fast`)时,容器必须包含 `overflow-auto` 类。
|
||||
4. **指令 DOM 保护**:严禁使用原生 DOM API 直接修改由 `$each`、`$if` 或组件渲染指令生成的 DOM 节点。所有 DOM 状态的变化应当完全通过修改与之绑定的底层 `State` 属性驱动。
|
||||
5. **退出拦截约束**:在全局配置有 `State.exitBlocks > 0` 时,框架将强行拦截并警告任何刷新/关闭页面的行为。
|
||||
## 四、 最佳实践 (AI 指令)
|
||||
1. **拒绝手动 DOM 操作**: 优先使用 `$bind` 和 `$.state` 驱动 UI。
|
||||
2. **列表性能**: 大数据量(>50条)强制使用 `Fast` 系列组件。
|
||||
3. **样式优先**: 优先使用 Bootstrap 5 Utility classes (如 `d-flex`, `p-3`, `gap-2`)。
|
||||
4. **微任务**: 涉及 DOM 尺寸计算的代码必须包裹在 `Promise.resolve().then(() => { ... })` 中。
|
||||
|
||||
13
TEST.md
13
TEST.md
@ -3,19 +3,18 @@
|
||||
## 基准测试 (Benchmark)
|
||||
*测试环境: Playwright / Chromium*
|
||||
|
||||
| 指标 | v1.0.0 | v1.0.1 | v1.0.3 | v1.0.4 | v1.0.7 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **FastList Render & Scroll (10k items)** | ~535ms | ~473ms | ~1513ms | ~54ms | ~53ms |
|
||||
| **FastGroupedList Render & Scroll (10k)** | ~705ms | ~51ms | ~51ms | ~1550ms | ~1560ms |
|
||||
| **FastTree Render & Scroll (10k items)** | ~927ms | ~50ms | ~51ms | ~1560ms | ~1570ms |
|
||||
| **CollapseTree Render & Scroll (1.2k)** | ~51ms | ~50ms | ~50ms | ~51ms | ~51ms |
|
||||
| 指标 | v1.0.0 | v1.0.1 | v1.0.3 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **FastList Render & Scroll (10k items)** | ~535ms | ~473ms | ~1513ms |
|
||||
| **FastGroupedList Render & Scroll (10k)** | ~705ms | ~51ms | ~51ms |
|
||||
| **FastTree Render & Scroll (10k items)** | ~927ms | ~50ms | ~51ms |
|
||||
| **CollapseTree Render & Scroll (1.2k)** | ~51ms | ~50ms | ~50ms |
|
||||
|
||||
## 测试覆盖 (Coverage)
|
||||
- [x] HTTP Request (GET/POST)
|
||||
- [x] API Component (Auto mode)
|
||||
- [x] Modal / Dialog / Toast UI
|
||||
- [x] AutoForm (Rendering & Validation)
|
||||
- [x] DatePicker / ColorPicker / IconPicker (New)
|
||||
- [x] FastList (Virtual Scrolling)
|
||||
- [x] Tree / Nav / Resizer
|
||||
- [x] Virtual List dynamic sizing check
|
||||
|
||||
1011
dist/base.js
vendored
1011
dist/base.js
vendored
File diff suppressed because one or more lines are too long
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.package-lock.json
generated
vendored
4
node_modules/.package-lock.json
generated
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apigo.cc/base",
|
||||
"version": "1.0.7",
|
||||
"name": "@web/base",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
17
node_modules/.vite/deps/_metadata.json
generated
vendored
17
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,15 +1,8 @@
|
||||
{
|
||||
"hash": "945ec9e8",
|
||||
"configHash": "6c629749",
|
||||
"lockfileHash": "463a0a64",
|
||||
"browserHash": "38e755dd",
|
||||
"optimized": {
|
||||
"bootstrap": {
|
||||
"src": "../../../../bootstrap/node_modules/bootstrap/dist/js/bootstrap.esm.js",
|
||||
"file": "bootstrap.js",
|
||||
"fileHash": "b9d6a4a2",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"hash": "a2c51734",
|
||||
"configHash": "a1c4c8e0",
|
||||
"lockfileHash": "99ec7e3e",
|
||||
"browserHash": "7e396f40",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
5188
node_modules/.vite/deps/bootstrap.js
generated
vendored
5188
node_modules/.vite/deps/bootstrap.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
node_modules/.vite/deps/bootstrap.js.map
generated
vendored
7
node_modules/.vite/deps/bootstrap.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@ -1,753 +0,0 @@
|
||||
// base.js v2.3
|
||||
(() => {
|
||||
const { Component, NewState } = globalThis
|
||||
|
||||
// 工具函数
|
||||
globalThis.Util = {
|
||||
clone: window.structuredClone || (obj => JSON.parse(JSON.stringify(obj))),
|
||||
base64: str => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||
unbase64: str => new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))),
|
||||
urlbase64: str => $base64(str).replace(/[+/=]/g, m => ({ '+': '-', '/': '', '=': '' }[m])),
|
||||
unurlbase64: str => $unbase64(str.replace(/[-_.]/g, m => ({ '-': '+', '_': '/', '.': '=' }[m])).padEnd(Math.ceil(str.length / 4) * 4, '=')),
|
||||
safeJson: str => { try { return JSON.parse(str) } catch { return null } },
|
||||
updateDefaults: (obj, defaults) => { for (const k in defaults) if (obj[k] === undefined) obj[k] = defaults[k] },
|
||||
copyFunction: (toObj, fromObj, ...funcNames) => { funcNames.forEach(name => toObj[name] = fromObj[name].bind(fromObj)) },
|
||||
getFunctionBody: fn => { const code = fn.toString(); return code.slice(code.indexOf('{') + 1, code.lastIndexOf('}')).trim() },
|
||||
makeDom: html => {
|
||||
if (html.includes('>\n')) html = html.replace(/>\s+</g, "><").trim()
|
||||
const node = document.createElement('div')
|
||||
node.innerHTML = html
|
||||
return node.children[0]
|
||||
},
|
||||
newAvg: () => {
|
||||
let total = 0, count = 0, avg = 0
|
||||
return {
|
||||
add: (v) => {
|
||||
total += v
|
||||
count++
|
||||
return avg = total / count
|
||||
},
|
||||
get: () => avg,
|
||||
clear: () => { total = 0, count = 0, avg = 0 }
|
||||
}
|
||||
},
|
||||
newTimeCount: () => {
|
||||
let startTime = 0, total = 0, count = 0
|
||||
return {
|
||||
start: () => startTime = new Date().getTime(),
|
||||
end: () => {
|
||||
const endTime = new Date().getTime()
|
||||
const left = endTime - startTime
|
||||
startTime = endTime
|
||||
total += left
|
||||
count++
|
||||
return left
|
||||
},
|
||||
avg: () => total / count
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// url hash 状态
|
||||
let _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
globalThis.Hash = NewState({}, k => Util.safeJson(_hashParams.get(k)), (k, v) => {
|
||||
const oldStr = _hashParams.get(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? _hashParams.delete(k) : _hashParams.set(k, newStr)
|
||||
window.location.hash = '#' + _hashParams.toString()
|
||||
})
|
||||
window.addEventListener('hashchange', () => {
|
||||
const oldHashParams = _hashParams
|
||||
_hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
_hashParams.forEach((v, k) => { if (oldHashParams.get(k) !== v) Hash[k] = Util.safeJson(v) })
|
||||
oldHashParams.forEach((v, k) => { if (_hashParams.get(k) === undefined) Hash[k] = undefined })
|
||||
})
|
||||
|
||||
// localstorage 状态
|
||||
globalThis.LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem(k)), (k, v) => {
|
||||
const oldStr = localStorage.getItem(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
|
||||
})
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const htmlNode = document.documentElement
|
||||
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'")
|
||||
}, true)
|
||||
|
||||
// 页面退出状态
|
||||
globalThis.State = NewState()
|
||||
State.exitBlocks = 0
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (State.exitBlocks > 0) event.preventDefault()
|
||||
})
|
||||
|
||||
// HTTP 和 API 组件
|
||||
globalThis.HTTP = {
|
||||
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
|
||||
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
|
||||
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
|
||||
delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
|
||||
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
|
||||
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
|
||||
method = method.toUpperCase()
|
||||
const options = { method, signal: AbortSignal.timeout?.(timeout) }
|
||||
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
if (data instanceof HTMLFormElement) data = new FormData(data)
|
||||
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
|
||||
else if (v !== undefined && v !== null) fd.append(k, v)
|
||||
}
|
||||
data = fd
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
delete headers['Content-Type']
|
||||
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
|
||||
data = JSON.stringify(data)
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
options.body = data
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
|
||||
try {
|
||||
const resp = await fetch(url, options)
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) responseType = 'json'
|
||||
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
|
||||
else responseType = 'text'
|
||||
response.responseType = responseType
|
||||
}
|
||||
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
|
||||
if (responseType === 'json') response.result = await resp.json()
|
||||
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
|
||||
} catch (err) {
|
||||
Object.assign(response, { error: err.message || String(err), ok: false })
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('API', container => {
|
||||
container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = NewState()
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
if (!req.url) throw new Error('.url is required')
|
||||
req.headers = { ...container.request.headers, ...opt.headers }
|
||||
container.response.loading = true
|
||||
HTTP.request(req).then(resp => {
|
||||
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
|
||||
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
|
||||
Object.assign(container.result, resp.result)
|
||||
} else {
|
||||
container.result = resp.result
|
||||
}
|
||||
container.response.loading = false
|
||||
if (resp.ok === false) throw new Error(resp.error)
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
resolve(resp)
|
||||
}).catch(err => {
|
||||
if (!opt.noui) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
let _autoTimer = null
|
||||
container.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.request.url) return
|
||||
if (_autoTimer) return
|
||||
_autoTimer = Promise.resolve().then(() => {
|
||||
container.do()
|
||||
_autoTimer = null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
globalThis.UI = {}
|
||||
// 模态窗口和对话框组件
|
||||
Component.register('Modal', container => {
|
||||
container.modal = new bootstrap.Modal(container)
|
||||
container.addEventListener('bind', e => {
|
||||
e.detail ? container.modal.show() : container.modal.hide()
|
||||
})
|
||||
container.addEventListener('hide.bs.modal', () => {
|
||||
document.activeElement?.blur()
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
|
||||
})
|
||||
Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div slot-id="header" class="modal-header">
|
||||
<h6 class="modal-title" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body"></div>
|
||||
<div slot-id="footer" class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div $if="this.state?.title" class="modal-header" $text="this.state?.title"></div>
|
||||
<div slot-id="body" class="modal-body"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" class="btn btn-sm btn-\${index === buttons.length - 1 ? (type && type !== 'body' ? type : 'primary') : 'secondary'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
const d = document.body.appendChild(document.createElement('Dialog'))
|
||||
d.style.zIndex = 2000 + ++_dialogCount
|
||||
Promise.resolve().then(() => {
|
||||
Object.assign(d.state, { message, title, type, buttons })
|
||||
d.show()
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
d.addEventListener('change', e => {
|
||||
_dialogCount--
|
||||
resolve(d.result || 0)
|
||||
d.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
UI.alert = function (message, options = {}) {
|
||||
return UI.showDialog({ message, ...options })
|
||||
}
|
||||
UI.confirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
// 通知组件
|
||||
Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
Util.copyFunction(container, container.toast, 'show', 'hide')
|
||||
container.addEventListener('show.bs.toast', () => {
|
||||
if (container.state.delay > 0) {
|
||||
let timer
|
||||
const startTimer = () => {
|
||||
container.state.left = container.state.delay / 1000
|
||||
timer = setInterval(() => {
|
||||
if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
startTimer()
|
||||
container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
|
||||
container.addEventListener('mouseleave', startTimer)
|
||||
}
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
|
||||
|
||||
UI.toast = function (message, options = {}) {
|
||||
const delay = options.delay ?? 5000
|
||||
const t = document.createElement('Toast')
|
||||
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'body', message, buttons: options.buttons || [] }
|
||||
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
|
||||
Promise.resolve().then(() => t.show())
|
||||
}
|
||||
|
||||
UI.toastConfirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
Component.register('AutoForm', container => {
|
||||
if (!container.schema) container.schema = []
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
container.data = NewState(container.data || {})
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return UI.toast('{#verify failed#}', { type: 'danger' })
|
||||
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
|
||||
const req = { ...container.request, data: container.data, noui: true, ...opt }
|
||||
let task = null
|
||||
if (container.api) task = container.api.do(req)
|
||||
else if (container.request.url) task = HTTP.request(req)
|
||||
else return console.warn('{#please config .api or .request.url to auto submit#}')
|
||||
task.then(resp => {
|
||||
container.response = resp
|
||||
container.result = resp.result
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
}).catch(err => {
|
||||
UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div>
|
||||
<form $class="align-items-center \${this.vertical?'':'auto-grid-form'}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<div $each="this.schema || []" style="display:contents">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper class="mb-3">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting" $bind="this.data[item.name]">
|
||||
<select $if="item.type === 'select'" $name="item.name" class="form-select" $.="item.setting" $bind="this.data[item.name]">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" >
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check\${item.vertical ? '' : ' form-check-inline'}">
|
||||
<input $name="item.name" class="form-check-input" $type="item.type" $.="item.setting" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div $if="item.type==='switch'" class="form-check form-switch fs-4"><input $name="item.name" class="form-check-input my-0" type="checkbox" $bind="this.data[item.name]"></div>
|
||||
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>@media (min-width: 576px) { .auto-grid-form {display: grid;grid-template-columns: max-content 1fr} .auto-grid-form .col-form-label {text-align: right; margin-bottom: 1rem;padding-right: 1rem;max-width: 200px} }</style>`))
|
||||
|
||||
const _pendingAutoFormComponents = []
|
||||
globalThis.AutoForm = {
|
||||
register: name => {
|
||||
if (document.readyState !== 'loading') AutoForm._addAutoFormComponent(name)
|
||||
else _pendingAutoFormComponents.push(name)
|
||||
},
|
||||
_addAutoFormComponent: name => {
|
||||
$(Component.getTemplate('AutoForm')?.content, '[control-wrapper]').appendChild(Util.makeDom(`<${name} $if="item.type.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting" $bind="this.data[item.name]"></${name}>`))
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
|
||||
_pendingAutoFormComponents.length = 0
|
||||
}, true)
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = e.detail || []
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this.state.tags.splice(index, 1)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
Promise.resolve().then(() => {
|
||||
const buttons = $$(this, 'button');
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}" $text="item"></button>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (event.isComposing) return
|
||||
if (['Enter', ',', ' '].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
const v = thisNode.value.trim()
|
||||
if (v && !this.state.tags.includes(v)) {
|
||||
this.state.tags.push(v)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
}
|
||||
thisNode.value = ''
|
||||
}
|
||||
})}">
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
AutoForm.register('TagsInput')
|
||||
|
||||
Component.register('FastList', container => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
container.state.renderedList = []
|
||||
const avg = Util.newAvg()
|
||||
let containerPaddingTop = 0
|
||||
let containerRowGap = 0
|
||||
let topMargin = 0
|
||||
let listStartIndex = 0
|
||||
let visibleStartIndex = 0
|
||||
let visibleCount = 10
|
||||
let listInited = false
|
||||
|
||||
container.onItemUpdate = (index, node) => {
|
||||
const absoluteIndex = index + listStartIndex
|
||||
const oldHeight = itemHeights.get(absoluteIndex)
|
||||
const style = window.getComputedStyle(node)
|
||||
const marginTop = parseFloat(style.marginTop) || 0
|
||||
const marginBottom = parseFloat(style.marginBottom) || 0
|
||||
const newHeight = node.offsetHeight + marginTop + marginBottom + containerRowGap
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = marginTop
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight)
|
||||
avg.add(newHeight)
|
||||
const offset = newHeight - oldHeight || 0
|
||||
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
|
||||
}
|
||||
}
|
||||
|
||||
// const timeCount = Util.newTimeCount()
|
||||
container.refresh = () => {
|
||||
if (!listInited) return
|
||||
// timeCount.start()
|
||||
visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
|
||||
let list = []
|
||||
const fullList = container.state.list
|
||||
const fullSize = fullList.length
|
||||
let i = 0
|
||||
let prev = containerPaddingTop + topMargin + containerRowGap
|
||||
let post = 0
|
||||
let starus = 0
|
||||
for (i = 0; i < fullSize; i++) {
|
||||
if (starus === 0) {
|
||||
// 可视位置之前的计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh && prev + gh < container.scrollTop) {
|
||||
prev += gh
|
||||
i += Math.min(groupItemCount, fullSize - i) - 1
|
||||
} else {
|
||||
const ih = itemHeights.get(i)
|
||||
if (prev + ih < container.scrollTop) {
|
||||
prev += ih
|
||||
} else {
|
||||
starus = 1
|
||||
// 可视位置处理
|
||||
visibleStartIndex = Math.max(0, i)
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
|
||||
const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
|
||||
i = listEndIndex - 1
|
||||
list = fullList.slice(listStartIndex, listEndIndex)
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
|
||||
}
|
||||
}
|
||||
} else if (starus === 1) {
|
||||
// 底部填充计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh) {
|
||||
post += gh
|
||||
i += groupItemCount - 1
|
||||
} else {
|
||||
post += itemHeights.get(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.info('FastList refresh make list:', timeCount.end())
|
||||
container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
|
||||
container.state.postHeight = post
|
||||
container.state.renderedList = list
|
||||
// console.info('FastList refresh time:', timeCount.end())
|
||||
}
|
||||
container.state.__watch('list', list => {
|
||||
listInited = false
|
||||
itemHeights.clear()
|
||||
groupHeights.clear()
|
||||
avg.clear()
|
||||
if (!list?.length) return
|
||||
|
||||
const listSize = list.length || 0
|
||||
groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
|
||||
const style = window.getComputedStyle(container)
|
||||
containerPaddingTop = parseFloat(style.paddingTop)
|
||||
containerRowGap = parseFloat(style.rowGap) || 0
|
||||
container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
|
||||
setTimeout(() => {
|
||||
// 渲染一批之后立刻计算所有高度
|
||||
container.state.list.forEach((item, index) => itemHeights.set(index, avg.get()))
|
||||
for (let i = 0; i < listSize; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, listSize - i) * avg.get())
|
||||
listInited = true
|
||||
container.refresh()
|
||||
})
|
||||
})
|
||||
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
|
||||
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
|
||||
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
|
||||
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
|
||||
container[selectActionName] = (item, index) => {
|
||||
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
|
||||
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
|
||||
}
|
||||
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
|
||||
}
|
||||
Component.register('List', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('GroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
container._selectItem = container.selectItem
|
||||
container.selectItem = (item, index) => {
|
||||
container.state.selectedGroup = null
|
||||
container._selectItem(item, index)
|
||||
}
|
||||
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select') && 'group-selectable')
|
||||
container._selectGroup = container.selectGroup
|
||||
container.selectGroup = (item, index) => {
|
||||
container.state.selectedItem = null
|
||||
container._selectGroup(item, index)
|
||||
}
|
||||
|
||||
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const newList = [];
|
||||
(container.state.groups || []).forEach(group => {
|
||||
newList.push({ [container.typefield]: 'group', ...group })
|
||||
if (oldList) oldList.forEach(item => {
|
||||
if (item[container.groupfield] === group[container.groupidfield]) {
|
||||
newList.push({ [container.typefield]: 'item', ...item })
|
||||
}
|
||||
})
|
||||
})
|
||||
aa = container
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<template $each="this.state.list">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('FastGroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('GroupedList')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group">
|
||||
<div slot="item">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('Tree', container => {
|
||||
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1)
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0)
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]+'!!'"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('FastTree', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('Tree')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group list-group-action">
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('CollapseTree', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.collapsed = NewState({})
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level, parents) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
|
||||
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('Nav', container => {
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="navbar navbar-expand bg-body-secondary px-3 pb-0 border-bottom align-items-center">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" class="me-2" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} me-2"></i>
|
||||
<span $if="this.state.brand.label" class="me-2" $text="this.state.brand.label"></span>
|
||||
<div class="ms-2"></div>
|
||||
<div $each="this.state.list" $class="navbar-nav text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
globalThis.MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
document.addEventListener('mouseup', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverMoving = false
|
||||
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
|
||||
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
|
||||
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
|
||||
Component.register('Resizer', container => {
|
||||
const isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = isVertical ? target.offsetHeight : target.offsetWidth
|
||||
MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => target.style[isVertical ? 'height' : 'width'] = getSize(startSize, w, h) + 'px',
|
||||
onmouseup: ({ w, h }) => container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize: getSize(startSize, w, h) }, bubbles: false })),
|
||||
})
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'}" $style="\${this.isVertical?'height':'width'}:3px;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
|
||||
})()
|
||||
@ -1,753 +0,0 @@
|
||||
// base.js v2.3
|
||||
(() => {
|
||||
const { Component, NewState } = globalThis
|
||||
|
||||
// 工具函数
|
||||
globalThis.Util = {
|
||||
clone: window.structuredClone || (obj => JSON.parse(JSON.stringify(obj))),
|
||||
base64: str => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||
unbase64: str => new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))),
|
||||
urlbase64: str => $base64(str).replace(/[+/=]/g, m => ({ '+': '-', '/': '', '=': '' }[m])),
|
||||
unurlbase64: str => $unbase64(str.replace(/[-_.]/g, m => ({ '-': '+', '_': '/', '.': '=' }[m])).padEnd(Math.ceil(str.length / 4) * 4, '=')),
|
||||
safeJson: str => { try { return JSON.parse(str) } catch { return null } },
|
||||
updateDefaults: (obj, defaults) => { for (const k in defaults) if (obj[k] === undefined) obj[k] = defaults[k] },
|
||||
copyFunction: (toObj, fromObj, ...funcNames) => { funcNames.forEach(name => toObj[name] = fromObj[name].bind(fromObj)) },
|
||||
getFunctionBody: fn => { const code = fn.toString(); return code.slice(code.indexOf('{') + 1, code.lastIndexOf('}')).trim() },
|
||||
makeDom: html => {
|
||||
if (html.includes('>\n')) html = html.replace(/>\s+</g, "><").trim()
|
||||
const node = document.createElement('div')
|
||||
node.innerHTML = html
|
||||
return node.children[0]
|
||||
},
|
||||
newAvg: () => {
|
||||
let total = 0, count = 0, avg = 0
|
||||
return {
|
||||
add: (v) => {
|
||||
total += v
|
||||
count++
|
||||
return avg = total / count
|
||||
},
|
||||
get: () => avg,
|
||||
clear: () => { total = 0, count = 0, avg = 0 }
|
||||
}
|
||||
},
|
||||
newTimeCount: () => {
|
||||
let startTime = 0, total = 0, count = 0
|
||||
return {
|
||||
start: () => startTime = new Date().getTime(),
|
||||
end: () => {
|
||||
const endTime = new Date().getTime()
|
||||
const left = endTime - startTime
|
||||
startTime = endTime
|
||||
total += left
|
||||
count++
|
||||
return left
|
||||
},
|
||||
avg: () => total / count
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// url hash 状态
|
||||
let _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
globalThis.Hash = NewState({}, k => Util.safeJson(_hashParams.get(k)), (k, v) => {
|
||||
const oldStr = _hashParams.get(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? _hashParams.delete(k) : _hashParams.set(k, newStr)
|
||||
window.location.hash = '#' + _hashParams.toString()
|
||||
})
|
||||
window.addEventListener('hashchange', () => {
|
||||
const oldHashParams = _hashParams
|
||||
_hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
_hashParams.forEach((v, k) => { if (oldHashParams.get(k) !== v) Hash[k] = Util.safeJson(v) })
|
||||
oldHashParams.forEach((v, k) => { if (_hashParams.get(k) === undefined) Hash[k] = undefined })
|
||||
})
|
||||
|
||||
// localstorage 状态
|
||||
globalThis.LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem(k)), (k, v) => {
|
||||
const oldStr = localStorage.getItem(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
|
||||
})
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const htmlNode = document.documentElement
|
||||
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'")
|
||||
}, true)
|
||||
|
||||
// 页面退出状态
|
||||
globalThis.State = NewState()
|
||||
State.exitBlocks = 0
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (State.exitBlocks > 0) event.preventDefault()
|
||||
})
|
||||
|
||||
// HTTP 和 API 组件
|
||||
globalThis.HTTP = {
|
||||
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
|
||||
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
|
||||
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
|
||||
delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
|
||||
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
|
||||
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
|
||||
method = method.toUpperCase()
|
||||
const options = { method, signal: AbortSignal.timeout?.(timeout) }
|
||||
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
if (data instanceof HTMLFormElement) data = new FormData(data)
|
||||
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
|
||||
else if (v !== undefined && v !== null) fd.append(k, v)
|
||||
}
|
||||
data = fd
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
delete headers['Content-Type']
|
||||
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
|
||||
data = JSON.stringify(data)
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
options.body = data
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
|
||||
try {
|
||||
const resp = await fetch(url, options)
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) responseType = 'json'
|
||||
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
|
||||
else responseType = 'text'
|
||||
response.responseType = responseType
|
||||
}
|
||||
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
|
||||
if (responseType === 'json') response.result = await resp.json()
|
||||
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
|
||||
} catch (err) {
|
||||
Object.assign(response, { error: err.message || String(err), ok: false })
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('API', container => {
|
||||
container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = NewState()
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
if (!req.url) throw new Error('.url is required')
|
||||
req.headers = { ...container.request.headers, ...opt.headers }
|
||||
container.response.loading = true
|
||||
HTTP.request(req).then(resp => {
|
||||
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
|
||||
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
|
||||
Object.assign(container.result, resp.result)
|
||||
} else {
|
||||
container.result = resp.result
|
||||
}
|
||||
container.response.loading = false
|
||||
if (resp.ok === false) throw new Error(resp.error)
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
resolve(resp)
|
||||
}).catch(err => {
|
||||
if (!opt.noui) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
let _autoTimer = null
|
||||
container.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.request.url) return
|
||||
if (_autoTimer) return
|
||||
_autoTimer = Promise.resolve().then(() => {
|
||||
container.do()
|
||||
_autoTimer = null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
globalThis.UI = {}
|
||||
// 模态窗口和对话框组件
|
||||
Component.register('Modal', container => {
|
||||
container.modal = new bootstrap.Modal(container)
|
||||
container.addEventListener('bind', e => {
|
||||
e.detail ? container.modal.show() : container.modal.hide()
|
||||
})
|
||||
container.addEventListener('hide.bs.modal', () => {
|
||||
document.activeElement?.blur()
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
|
||||
})
|
||||
Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div slot-id="header" class="modal-header">
|
||||
<h6 class="modal-title" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body"></div>
|
||||
<div slot-id="footer" class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div $if="this.state?.title" class="modal-header" $text="this.state?.title"></div>
|
||||
<div slot-id="body" class="modal-body"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" class="btn btn-sm btn-\${index === buttons.length - 1 ? (type && type !== 'body' ? type : 'primary') : 'secondary'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
const d = document.body.appendChild(document.createElement('Dialog'))
|
||||
d.style.zIndex = 2000 + ++_dialogCount
|
||||
Promise.resolve().then(() => {
|
||||
Object.assign(d.state, { message, title, type, buttons })
|
||||
d.show()
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
d.addEventListener('change', e => {
|
||||
_dialogCount--
|
||||
resolve(d.result || 0)
|
||||
d.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
UI.alert = function (message, options = {}) {
|
||||
return UI.showDialog({ message, ...options })
|
||||
}
|
||||
UI.confirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
// 通知组件
|
||||
Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
Util.copyFunction(container, container.toast, 'show', 'hide')
|
||||
container.addEventListener('show.bs.toast', () => {
|
||||
if (container.state.delay > 0) {
|
||||
let timer
|
||||
const startTimer = () => {
|
||||
container.state.left = container.state.delay / 1000
|
||||
timer = setInterval(() => {
|
||||
if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
startTimer()
|
||||
container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
|
||||
container.addEventListener('mouseleave', startTimer)
|
||||
}
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
|
||||
|
||||
UI.toast = function (message, options = {}) {
|
||||
const delay = options.delay ?? 5000
|
||||
const t = document.createElement('Toast')
|
||||
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'body', message, buttons: options.buttons || [] }
|
||||
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
|
||||
Promise.resolve().then(() => t.show())
|
||||
}
|
||||
|
||||
UI.toastConfirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
Component.register('AutoForm', container => {
|
||||
if (!container.schema) container.schema = []
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
container.data = NewState(container.data || {})
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return UI.toast('{#verify failed#}', { type: 'danger' })
|
||||
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
|
||||
const req = { ...container.request, data: container.data, noui: true, ...opt }
|
||||
let task = null
|
||||
if (container.api) task = container.api.do(req)
|
||||
else if (container.request.url) task = HTTP.request(req)
|
||||
else return console.warn('{#please config .api or .request.url to auto submit#}')
|
||||
task.then(resp => {
|
||||
container.response = resp
|
||||
container.result = resp.result
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
}).catch(err => {
|
||||
UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div>
|
||||
<form $class="align-items-center \${this.vertical?'':'auto-grid-form'}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<div $each="this.schema || []" style="display:contents">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper class="mb-3">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting" $bind="this.data[item.name]">
|
||||
<select $if="item.type === 'select'" $name="item.name" class="form-select" $.="item.setting" $bind="this.data[item.name]">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" >
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check\${item.vertical ? '' : ' form-check-inline'}">
|
||||
<input $name="item.name" class="form-check-input" $type="item.type" $.="item.setting" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div $if="item.type==='switch'" class="form-check form-switch fs-4"><input $name="item.name" class="form-check-input my-0" type="checkbox" $bind="this.data[item.name]"></div>
|
||||
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>@media (min-width: 576px) { .auto-grid-form {display: grid;grid-template-columns: max-content 1fr} .auto-grid-form .col-form-label {text-align: right; margin-bottom: 1rem;padding-right: 1rem;max-width: 200px} }</style>`))
|
||||
|
||||
const _pendingAutoFormComponents = []
|
||||
globalThis.AutoForm = {
|
||||
register: name => {
|
||||
if (document.readyState !== 'loading') AutoForm._addAutoFormComponent(name)
|
||||
else _pendingAutoFormComponents.push(name)
|
||||
},
|
||||
_addAutoFormComponent: name => {
|
||||
$(Component.getTemplate('AutoForm')?.content, '[control-wrapper]').appendChild(Util.makeDom(`<${name} $if="item.type.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting" $bind="this.data[item.name]"></${name}>`))
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
|
||||
_pendingAutoFormComponents.length = 0
|
||||
}, true)
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = e.detail || []
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this.state.tags.splice(index, 1)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
Promise.resolve().then(() => {
|
||||
const buttons = $$(this, 'button');
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}" $text="item"></button>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (event.isComposing) return
|
||||
if (['Enter', ',', ' '].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
const v = thisNode.value.trim()
|
||||
if (v && !this.state.tags.includes(v)) {
|
||||
this.state.tags.push(v)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
}
|
||||
thisNode.value = ''
|
||||
}
|
||||
})}">
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
AutoForm.register('TagsInput')
|
||||
|
||||
Component.register('FastList', container => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
container.state.renderedList = []
|
||||
const avg = Util.newAvg()
|
||||
let containerPaddingTop = 0
|
||||
let containerRowGap = 0
|
||||
let topMargin = 0
|
||||
let listStartIndex = 0
|
||||
let visibleStartIndex = 0
|
||||
let visibleCount = 10
|
||||
let listInited = false
|
||||
|
||||
container.onItemUpdate = (index, node) => {
|
||||
const absoluteIndex = index + listStartIndex
|
||||
const oldHeight = itemHeights.get(absoluteIndex)
|
||||
const style = window.getComputedStyle(node)
|
||||
const marginTop = parseFloat(style.marginTop) || 0
|
||||
const marginBottom = parseFloat(style.marginBottom) || 0
|
||||
const newHeight = node.offsetHeight + marginTop + marginBottom + containerRowGap
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = marginTop
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight)
|
||||
avg.add(newHeight)
|
||||
const offset = newHeight - oldHeight || 0
|
||||
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
|
||||
}
|
||||
}
|
||||
|
||||
// const timeCount = Util.newTimeCount()
|
||||
container.refresh = () => {
|
||||
if (!listInited) return
|
||||
// timeCount.start()
|
||||
visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
|
||||
let list = []
|
||||
const fullList = container.state.list
|
||||
const fullSize = fullList.length
|
||||
let i = 0
|
||||
let prev = containerPaddingTop + topMargin + containerRowGap
|
||||
let post = 0
|
||||
let starus = 0
|
||||
for (i = 0; i < fullSize; i++) {
|
||||
if (starus === 0) {
|
||||
// 可视位置之前的计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh && prev + gh < container.scrollTop) {
|
||||
prev += gh
|
||||
i += Math.min(groupItemCount, fullSize - i) - 1
|
||||
} else {
|
||||
const ih = itemHeights.get(i)
|
||||
if (prev + ih < container.scrollTop) {
|
||||
prev += ih
|
||||
} else {
|
||||
starus = 1
|
||||
// 可视位置处理
|
||||
visibleStartIndex = Math.max(0, i)
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
|
||||
const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
|
||||
i = listEndIndex - 1
|
||||
list = fullList.slice(listStartIndex, listEndIndex)
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
|
||||
}
|
||||
}
|
||||
} else if (starus === 1) {
|
||||
// 底部填充计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh) {
|
||||
post += gh
|
||||
i += groupItemCount - 1
|
||||
} else {
|
||||
post += itemHeights.get(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.info('FastList refresh make list:', timeCount.end())
|
||||
container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
|
||||
container.state.postHeight = post
|
||||
container.state.renderedList = list
|
||||
// console.info('FastList refresh time:', timeCount.end())
|
||||
}
|
||||
container.state.__watch('list', list => {
|
||||
listInited = false
|
||||
itemHeights.clear()
|
||||
groupHeights.clear()
|
||||
avg.clear()
|
||||
if (!list?.length) return
|
||||
|
||||
const listSize = list.length || 0
|
||||
groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
|
||||
const style = window.getComputedStyle(container)
|
||||
containerPaddingTop = parseFloat(style.paddingTop)
|
||||
containerRowGap = parseFloat(style.rowGap) || 0
|
||||
container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
|
||||
setTimeout(() => {
|
||||
// 渲染一批之后立刻计算所有高度
|
||||
container.state.list.forEach((item, index) => itemHeights.set(index, avg.get()))
|
||||
for (let i = 0; i < listSize; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, listSize - i) * avg.get())
|
||||
listInited = true
|
||||
container.refresh()
|
||||
})
|
||||
})
|
||||
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
|
||||
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
|
||||
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
|
||||
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
|
||||
container[selectActionName] = (item, index) => {
|
||||
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
|
||||
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
|
||||
}
|
||||
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
|
||||
}
|
||||
Component.register('List', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('GroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
container._selectItem = container.selectItem
|
||||
container.selectItem = (item, index) => {
|
||||
container.state.selectedGroup = null
|
||||
container._selectItem(item, index)
|
||||
}
|
||||
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select') && 'group-selectable')
|
||||
container._selectGroup = container.selectGroup
|
||||
container.selectGroup = (item, index) => {
|
||||
container.state.selectedItem = null
|
||||
container._selectGroup(item, index)
|
||||
}
|
||||
|
||||
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const newList = [];
|
||||
(container.state.groups || []).forEach(group => {
|
||||
newList.push({ [container.typefield]: 'group', ...group })
|
||||
if (oldList) oldList.forEach(item => {
|
||||
if (item[container.groupfield] === group[container.groupidfield]) {
|
||||
newList.push({ [container.typefield]: 'item', ...item })
|
||||
}
|
||||
})
|
||||
})
|
||||
aa = container
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<template $each="this.state.list">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('FastGroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('GroupedList')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group">
|
||||
<div slot="item">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('Tree', container => {
|
||||
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1)
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0)
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]+'!!'"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('FastTree', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('Tree')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group list-group-action">
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('CollapseTree', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.collapsed = NewState({})
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level, parents) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
|
||||
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('Nav', container => {
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="navbar navbar-expand bg-body-secondary px-3 pb-0 border-bottom align-items-center">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" class="me-2" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} me-2"></i>
|
||||
<span $if="this.state.brand.label" class="me-2" $text="this.state.brand.label"></span>
|
||||
<div class="ms-2"></div>
|
||||
<div $each="this.state.list" $class="navbar-nav text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
globalThis.MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
document.addEventListener('mouseup', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverMoving = false
|
||||
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
|
||||
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
|
||||
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
|
||||
Component.register('Resizer', container => {
|
||||
const isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = isVertical ? target.offsetHeight : target.offsetWidth
|
||||
MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => target.style[isVertical ? 'height' : 'width'] = getSize(startSize, w, h) + 'px',
|
||||
onmouseup: ({ w, h }) => container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize: getSize(startSize, w, h) }, bubbles: false })),
|
||||
})
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'}" $style="\${this.isVertical?'height':'width'}:3px;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
|
||||
})()
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@apigo.cc/base",
|
||||
"version": "1.0.7",
|
||||
"name": "@web/base",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@apigo.cc/base",
|
||||
"version": "1.0.7",
|
||||
"name": "@web/base",
|
||||
"version": "1.0.2",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@apigo.cc/base",
|
||||
"version": "1.0.17",
|
||||
"name": "@web/base",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"main": "dist/base.js",
|
||||
"module": "dist/base.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "playwright test",
|
||||
"pub": "node scripts/publish.js"
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
try {
|
||||
// 1. 获取最新 tag
|
||||
let tag;
|
||||
try {
|
||||
tag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||
} catch (err) {
|
||||
throw new Error('Failed to find git tags. Please make sure the repository has tags (e.g., v1.0.0) before publishing.');
|
||||
}
|
||||
// 去掉 v 前缀
|
||||
const version = tag.startsWith('v') ? tag.slice(1) : tag;
|
||||
|
||||
console.log(`Latest git tag: ${tag}, Version to publish: ${version}`);
|
||||
|
||||
// 2. 读取并更新 package.json
|
||||
const pkgPath = path.join(__dirname, '../package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
|
||||
// 保持原有名称(如果已经带有 @apigo.cc/ 前缀)或替换前缀
|
||||
if (!pkg.name.startsWith('@apigo.cc/')) {
|
||||
const baseName = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
|
||||
pkg.name = `@apigo.cc/${baseName}`;
|
||||
}
|
||||
pkg.version = version;
|
||||
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(`Updated package.json: name=${pkg.name}, version=${pkg.version}`);
|
||||
|
||||
// 3. 构建
|
||||
console.log('Running build...');
|
||||
execSync('npm run build', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
|
||||
|
||||
// 4. 发布
|
||||
console.log('Publishing to npm...');
|
||||
const args = process.argv.slice(2).join(' ');
|
||||
execSync(`npm publish --access public ${args}`, { stdio: 'inherit', cwd: path.join(__dirname, '..') });
|
||||
|
||||
console.log('Publish successful!');
|
||||
} catch (error) {
|
||||
console.error('Publish failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
42
src/controls.js
vendored
42
src/controls.js
vendored
File diff suppressed because one or more lines are too long
195
src/form.js
195
src/form.js
@ -1,98 +1,120 @@
|
||||
// src/form.js
|
||||
(function(global) {
|
||||
const { Component, NewState, Util, $, State } = global;
|
||||
import { Component, NewState, Util, $ } from '@web/state'
|
||||
import { HTTP } from './http.js'
|
||||
|
||||
const AutoForm = {
|
||||
customTypes: [],
|
||||
register: (name, typeName) => {
|
||||
const type = typeName || name
|
||||
if (!AutoForm.customTypes.includes(type)) AutoForm.customTypes.push(type)
|
||||
}
|
||||
};
|
||||
|
||||
Component.register('AutoForm', container => {
|
||||
Component.register('AutoForm', container => {
|
||||
if (!container.state.schema) container.state.schema = []
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.inline = container.hasAttribute('inline')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
|
||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? NewState(v) : v;
|
||||
container.state.__watch('data', v => container.data = ensureProxy(v));
|
||||
container.data = ensureProxy(container.state.data || {});
|
||||
|
||||
container.addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
if (!container.form.reportValidity()) return global.UI?.toast?.('{#verify failed#}', { type: 'danger' })
|
||||
|
||||
container.state.formState = 'submitting'
|
||||
const detail = JSON.parse(JSON.stringify(container.data))
|
||||
const customEvent = new CustomEvent('submit', { bubbles: false, cancelable: true, detail })
|
||||
container.dispatchEvent(customEvent)
|
||||
|
||||
if (customEvent.defaultPrevented) return
|
||||
try {
|
||||
if (container.state.action) {
|
||||
const resp = await global.HTTP.request({ url: container.state.action, method: 'POST', data: detail })
|
||||
if (!resp.ok) throw new Error(resp.error)
|
||||
// 初始化数据代理与联动逻辑
|
||||
const setupData = () => {
|
||||
if (!container.data || !container.data.__watch) {
|
||||
container.data = NewState(container.data || {})
|
||||
}
|
||||
container.state.formState = 'success'
|
||||
if (global.UI?.toast) global.UI.toast('{#submit success#}', { type: 'success' })
|
||||
} catch (err) {
|
||||
container.state.formState = 'error'
|
||||
if (global.UI?.toast) global.UI.toast(err.message, { type: 'danger' })
|
||||
container.data.__watch('*', () => {
|
||||
if (container.inline) {
|
||||
const dt = container.closest('DataTable')
|
||||
if (dt && dt.refresh) dt.refresh()
|
||||
}
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<form class="auto-form-root" $onsubmit="this.dispatchEvent(new Event('submit'))">
|
||||
<div $class="auto-grid-form \${this.state?.grid ? 'row g-3' : ''}">
|
||||
<template $each="this.state?.schema" as="field">
|
||||
<div $class="\${this.state?.grid ? 'col-md-' + (field.col || 12) : 'mb-3'}">
|
||||
<label $if="field.label" class="form-label small fw-bold text-muted mb-1" $text="field.label"></label>
|
||||
<div class="field-container">
|
||||
<template $if="!field.type || field.type === 'text'">
|
||||
<input type="text" class="form-control" $bind="this.data[field.id]" $required="field.required" $placeholder="field.placeholder || ''">
|
||||
</template>
|
||||
<template $if="field.type === 'textarea'">
|
||||
<textarea class="form-control" $bind="this.data[field.id]" $required="field.required" $placeholder="field.placeholder || ''" $rows="field.rows || 3"></textarea>
|
||||
</template>
|
||||
<template $if="field.type === 'select'">
|
||||
<select class="form-select" $bind="this.data[field.id]" $required="field.required">
|
||||
<template $each="field.options" as="opt">
|
||||
<option $value="typeof opt === 'object' ? opt.value : opt" $text="typeof opt === 'object' ? opt.label : opt"></option>
|
||||
</template>
|
||||
}
|
||||
|
||||
// 如果 data 已经存在(可能是同步绑定的),立即设置;否则延迟一帧等待属性绑定完成
|
||||
if (container.data) setupData()
|
||||
else requestAnimationFrame(setupData)
|
||||
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
|
||||
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
|
||||
const req = { ...container.request, data: container.data, noui: true, ...opt }
|
||||
let task = null
|
||||
if (container.api) task = container.api.do(req)
|
||||
else if (container.request.url) task = HTTP.request(req)
|
||||
else return console.warn('{#please config .api or .request.url to auto submit#}')
|
||||
task.then(resp => {
|
||||
container.response = resp
|
||||
container.result = resp.result
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
}).catch(err => {
|
||||
if (globalThis.UI?.toast) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="'auto-form-root' + (this.inline ? ' auto-form-inline min-h-100 w-100' : '')">
|
||||
<form $class="'h-100 w-100 d-flex ' + (this.inline ? 'align-items-center' : (this.vertical ? 'flex-column' : 'auto-grid-form'))" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<div $each="this.state.schema || []" style="display:contents">
|
||||
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper $class="this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="item.type === 'number' ? 'text-end' : ''">
|
||||
<select $if="item.type === 'select'" $name="item.name" class="form-select" $.="item.setting || {}" $bind="this.data[item.name]">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
</template>
|
||||
<template $if="field.type === 'switch'">
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" $bind="this.data[field.id]">
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" $class="this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100' : ''" style="white-space:nowrap">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="'form-check' + (item.vertical ? '' : ' form-check-inline') + ' mb-0 d-flex align-items-center p-0'">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template $each="AutoForm.customTypes" as="type">
|
||||
<template $if="field.type === type">
|
||||
<div $is="type" $.data="this.data[field.id]" $onchange="this.data[field.id] = event.detail"></div>
|
||||
</template>
|
||||
</template>
|
||||
<div $if="item.type==='switch'" class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 justify-content-center w-100" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
|
||||
</div>
|
||||
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="!this.inline" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
<div $if="!this.state?.hideSubmit" class="mt-4 pt-2 border-top d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-light px-4" $if="this.state?.showCancel" $onclick="this.dispatchEvent(new CustomEvent('cancel'))">{#Cancel#}</button>
|
||||
<button type="submit" class="btn btn-primary px-4" $disabled="this.state?.formState === 'submitting'">
|
||||
<span $if="this.state?.formState === 'submitting'" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span $text="this.state?.submitLabel || '{#Submit#}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`))
|
||||
</form>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>
|
||||
@media (min-width: 576px) { .auto-grid-form {display: grid;grid-template-columns: max-content 1fr} .auto-grid-form .col-form-label {text-align: right; margin-bottom: 1rem;padding-right: 1rem;max-width: 200px} }
|
||||
.auto-form-inline { background-color: var(--bs-body-bg); }
|
||||
.auto-form-inline.min-h-100 { min-height: 100%; height: auto; }
|
||||
.auto-form-inline .form-control, .auto-form-inline .form-select { border-radius: 0; border: none; background-color: transparent !important; min-height: 100%; height: auto; width: 100%; padding: 0 8px; box-shadow: none; color: inherit; }
|
||||
.auto-form-inline .form-select { background-position: right 4px center; background-size: 12px 10px; padding-right: 20px; }
|
||||
.auto-form-inline textarea.form-control { padding: 8px; min-height: 100px; height: auto; }
|
||||
</style>`))
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
container._thisObj = container;
|
||||
if (!container.state) container.state = NewState({ tags: [] })
|
||||
const _pendingAutoFormComponents = []
|
||||
export const AutoForm = {
|
||||
register: name => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState !== 'loading' && Component.getTemplate('AutoForm')) AutoForm._addAutoFormComponent(name)
|
||||
else _pendingAutoFormComponents.push(name)
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: name => {
|
||||
const template = Component.getTemplate('AutoForm')
|
||||
if (template) {
|
||||
$(template.content, '[control-wrapper]')?.appendChild(Util.makeDom(`<${name} $if="item.type?.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]"></${name}>`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const initAutoForm = () => {
|
||||
_pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
|
||||
_pendingAutoFormComponents.length = 0
|
||||
}
|
||||
if (document.readyState !== 'loading') setTimeout(initAutoForm, 100)
|
||||
else document.addEventListener('DOMContentLoaded', () => setTimeout(initAutoForm, 100), true)
|
||||
}
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = Array.isArray(e.detail) ? e.detail : []
|
||||
container.state.tags = e.detail || []
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<template $each="this.state.tags">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(function (event) {
|
||||
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this.state.tags.splice(index, 1)
|
||||
@ -103,9 +125,8 @@
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}" $text="item"></button>
|
||||
</template>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(function (event) {
|
||||
})}" $text="item"></button>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (event.isComposing) return
|
||||
if (['Enter', ',', ' '].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
@ -117,12 +138,8 @@
|
||||
}
|
||||
thisNode.value = ''
|
||||
}
|
||||
})}">
|
||||
})}">
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
AutoForm.register('TagsInput');
|
||||
|
||||
global.AutoForm = AutoForm;
|
||||
|
||||
})(globalThis);
|
||||
AutoForm.register('TagsInput')
|
||||
|
||||
108
src/http.js
108
src/http.js
@ -1,8 +1,6 @@
|
||||
// src/http.js
|
||||
(function(global) {
|
||||
const { Component, NewState } = global;
|
||||
import { Component, NewState } from '@web/state'
|
||||
|
||||
const HTTP = {
|
||||
export const HTTP = {
|
||||
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
|
||||
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
|
||||
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
|
||||
@ -10,52 +8,84 @@
|
||||
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
|
||||
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
|
||||
method = method.toUpperCase()
|
||||
const opt = { method, headers: { 'Content-Type': 'application/json', ...headers } }
|
||||
if (data !== undefined) opt.body = JSON.stringify(data)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeout)
|
||||
opt.signal = controller.signal
|
||||
|
||||
const options = { method, signal: AbortSignal.timeout?.(timeout) }
|
||||
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
if (data instanceof HTMLFormElement) data = new FormData(data)
|
||||
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
|
||||
else if (v !== undefined && v !== null) fd.append(k, v)
|
||||
}
|
||||
data = fd
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
delete headers['Content-Type']
|
||||
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
|
||||
data = JSON.stringify(data)
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
options.body = data
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
|
||||
try {
|
||||
const response = await fetch(url, opt)
|
||||
clearTimeout(timer)
|
||||
const result = responseType === 'blob' ? await response.blob() : (responseType === 'text' ? await response.text() : await response.json())
|
||||
return { ok: response.ok, status: response.status, result }
|
||||
const resp = await fetch(url, options)
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) responseType = 'json'
|
||||
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
|
||||
else responseType = 'text'
|
||||
response.responseType = responseType
|
||||
}
|
||||
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
|
||||
if (responseType === 'json') response.result = await resp.json()
|
||||
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
return { ok: false, status: 0, error: err.message }
|
||||
Object.assign(response, { error: err.message || String(err), ok: false })
|
||||
}
|
||||
return response
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Component.register('API', container => {
|
||||
if (!container.state.request) container.state.request = { url: '', method: 'GET', data: {}, noui: false }
|
||||
if (!container.state.response) container.state.response = { ok: false, status: 0, result: null, loading: false }
|
||||
|
||||
container.do = async (req) => {
|
||||
const opt = { ...container.state.request, ...req }
|
||||
container.state.response.loading = true
|
||||
const resp = await HTTP.request(opt)
|
||||
Object.assign(container.state.response, resp, { loading: false })
|
||||
if (!resp.ok) {
|
||||
if (!opt.noui && global.UI?.toast) global.UI.toast(resp.error || 'Request failed', { type: 'danger' })
|
||||
// HTTP 和 API 组件
|
||||
export const APIComponent = Component.register('API', container => {
|
||||
container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = NewState()
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
if (!req.url) throw new Error('.url is required')
|
||||
req.headers = { ...container.request.headers, ...opt.headers }
|
||||
container.response.loading = true
|
||||
HTTP.request(req).then(resp => {
|
||||
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
|
||||
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
|
||||
Object.assign(container.result, resp.result)
|
||||
} else {
|
||||
container.result = resp.result
|
||||
}
|
||||
container.dispatchEvent(new CustomEvent('success', { bubbles: false, detail: resp.result }))
|
||||
return resp
|
||||
container.response.loading = false
|
||||
if (resp.ok === false) throw new Error(resp.error)
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
resolve(resp)
|
||||
}).catch(err => {
|
||||
if (!opt.noui && globalThis.UI?.toast) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let _autoTimer = null
|
||||
container.state.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.state.request.url) return
|
||||
container.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.request.url) return
|
||||
if (_autoTimer) return
|
||||
_autoTimer = Promise.resolve().then(() => {
|
||||
container.do()
|
||||
_autoTimer = null
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
global.HTTP = HTTP;
|
||||
|
||||
})(globalThis);
|
||||
})
|
||||
|
||||
57
src/index.js
57
src/index.js
@ -1,20 +1,43 @@
|
||||
// src/index.js
|
||||
import '@apigo.cc/bootstrap'
|
||||
import './ui.js'
|
||||
import './http.js'
|
||||
import './form.js'
|
||||
import './list.js'
|
||||
import './interaction.js'
|
||||
import './controls.js'
|
||||
import './nav.js'
|
||||
import { NewState } from '@web/state'
|
||||
|
||||
// Additional global helpers or initializations for base can go here
|
||||
if (typeof document !== 'undefined') {
|
||||
// Example: global listener for exitBlocks
|
||||
// Re-exports
|
||||
export * from './http.js'
|
||||
export * from './ui.js'
|
||||
export * from './form.js'
|
||||
export * from './list.js'
|
||||
export * from './nav.js'
|
||||
export * from './interaction.js'
|
||||
|
||||
// 页面退出状态
|
||||
export const State = NewState({ exitBlocks: 0 })
|
||||
globalThis.State = State
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (globalThis.State?.exitBlocks > 0) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
if (State.exitBlocks > 0) event.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
const htmlNode = document.documentElement;
|
||||
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) {
|
||||
htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'");
|
||||
}
|
||||
|
||||
// Side effects: ensure global namespaces are populated
|
||||
import { HTTP } from './http.js'
|
||||
import { UI } from './ui.js'
|
||||
import { AutoForm } from './form.js'
|
||||
import { MouseMover } from './interaction.js'
|
||||
|
||||
globalThis.HTTP = HTTP
|
||||
globalThis.UI = UI
|
||||
globalThis.AutoForm = AutoForm
|
||||
globalThis.MouseMover = MouseMover
|
||||
|
||||
import { RefreshState } from '@web/state'
|
||||
if (typeof document !== 'undefined') {
|
||||
const doRefresh = () => RefreshState(document.documentElement)
|
||||
if (document.readyState !== 'loading') doRefresh()
|
||||
else document.addEventListener('DOMContentLoaded', doRefresh, true)
|
||||
}
|
||||
|
||||
|
||||
@ -1,73 +1,60 @@
|
||||
// src/interaction.js
|
||||
(function(global) {
|
||||
const { Component, Util } = global;
|
||||
import { Component, Util } from '@web/state'
|
||||
|
||||
const MouseMover = {
|
||||
bind: (handle, target, options = {}) => {
|
||||
let isMoving = false
|
||||
let startX, startY, startLeft, startTop
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
if (options.shouldStart && !options.shouldStart(e)) return
|
||||
isMoving = true
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
const rect = target.getBoundingClientRect()
|
||||
startLeft = rect.left
|
||||
startTop = rect.top
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
if (options.onStart) options.onStart(e)
|
||||
}
|
||||
export const MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isMoving) return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
if (options.axis !== 'y') target.style.left = (startLeft + dx) + 'px'
|
||||
if (options.axis !== 'x') target.style.top = (startTop + dy) + 'px'
|
||||
if (options.onMove) options.onStart(e)
|
||||
}
|
||||
|
||||
const onMouseUp = (e) => {
|
||||
isMoving = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
if (options.onEnd) options.onEnd(e)
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', onMouseDown)
|
||||
return () => handle.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('Resizer', container => {
|
||||
container.style.cursor = container.hasAttribute('vertical') ? 'col-resize' : 'row-resize'
|
||||
container.addEventListener('mousedown', e => {
|
||||
const target = container.parentElement
|
||||
if (!target) return
|
||||
const rect = target.getBoundingClientRect()
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const startW = rect.width
|
||||
const startH = rect.height
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const dw = e.clientX - startX
|
||||
const dh = e.clientY - startY
|
||||
if (container.hasAttribute('vertical')) target.style.width = (startW + dw) + 'px'
|
||||
else target.style.height = (startH + dh) + 'px'
|
||||
container.dispatchEvent(new CustomEvent('resize', { bubbles: false, detail: { width: target.offsetWidth, height: target.offsetHeight } }))
|
||||
}
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('mouseup', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverMoving = false
|
||||
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
}, Util.makeDom(/*html*/`<div style="position:absolute;right:0;bottom:0;width:10px;height:10px;z-index:100"></div>`))
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
|
||||
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
|
||||
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
}
|
||||
|
||||
global.MouseMover = MouseMover;
|
||||
|
||||
})(globalThis);
|
||||
Component.register('Resizer', container => {
|
||||
container.isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
container.addEventListener('bind', e => {
|
||||
if (e.detail !== undefined && e.detail !== null) {
|
||||
target.style[container.isVertical ? 'height' : 'width'] = e.detail + 'px'
|
||||
}
|
||||
})
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (container.isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = container.isVertical ? target.offsetHeight : target.offsetWidth
|
||||
MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
target.style[container.isVertical ? 'height' : 'width'] = newSize + 'px'
|
||||
container.dispatchEvent(new CustomEvent('resizing', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
},
|
||||
onmouseup: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: newSize, bubbles: false }))
|
||||
},
|
||||
})
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'} flex-shrink-0" $style="\${this.isVertical?'height':'width'}:3px;\${!this.isVertical?'height':'width'}:100%;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
|
||||
265
src/list.js
265
src/list.js
@ -1,127 +1,170 @@
|
||||
// src/list.js
|
||||
(function(global) {
|
||||
const { Component, NewState, Util, Hash } = global;
|
||||
import { Component, NewState, Util, Hash } from '@web/state'
|
||||
|
||||
const VirtualScroll = (options = {}) => {
|
||||
const config = {
|
||||
itemHeight: 50,
|
||||
buffer: 5,
|
||||
...options
|
||||
}
|
||||
|
||||
return (container) => {
|
||||
if (!container.state.list) container.state.list = []
|
||||
if (!container.state._renderedList) container.state._renderedList = []
|
||||
|
||||
let _lastScrollTop = 0
|
||||
let _ticking = false
|
||||
|
||||
const update = () => {
|
||||
const list = container.state.list || []
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
const itemHeight = config.itemHeight
|
||||
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - config.buffer)
|
||||
const endIndex = Math.min(list.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + config.buffer)
|
||||
|
||||
const rendered = []
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
rendered.push({
|
||||
...list[i],
|
||||
_top: i * itemHeight,
|
||||
_index: i
|
||||
})
|
||||
}
|
||||
|
||||
container.state._renderedList = rendered
|
||||
|
||||
const spacerPost = container.querySelector('.dt-spacer-post')
|
||||
if (spacerPost) {
|
||||
spacerPost.style.height = (list.length * itemHeight - (endIndex * itemHeight)) + 'px'
|
||||
spacerPost.style.display = 'block'
|
||||
}
|
||||
const spacerPrev = container.querySelector('.dt-spacer-prev')
|
||||
if (spacerPrev) {
|
||||
spacerPrev.style.height = (startIndex * itemHeight) + 'px'
|
||||
spacerPrev.style.display = 'block'
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', () => {
|
||||
if (!_ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
update()
|
||||
_ticking = false
|
||||
})
|
||||
_ticking = true
|
||||
}
|
||||
})
|
||||
|
||||
container.state.__watch('list', update)
|
||||
window.addEventListener('resize', update)
|
||||
Promise.resolve().then(update)
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('FastList', container => {
|
||||
export const VirtualScroll = () => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
container.state.renderedList = []
|
||||
const avg = Util.newAvg()
|
||||
let containerPaddingTop = 0
|
||||
let containerRowGap = 0
|
||||
let topMargin = 0
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
|
||||
|
||||
const update = () => {
|
||||
const list = container.state.list || []
|
||||
const groups = container.state.groups || []
|
||||
const scrollTop = container.scrollTop
|
||||
const viewHeight = container.clientHeight
|
||||
const itemHeight = container.state.itemHeight || 40
|
||||
|
||||
let currentTop = 0
|
||||
let startIndex = -1
|
||||
let endIndex = list.length
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const h = itemHeights.get(list[i].id) || itemHeight
|
||||
if (startIndex === -1 && currentTop + h > scrollTop - 200) startIndex = i
|
||||
if (startIndex !== -1 && currentTop > scrollTop + viewHeight + 200) {
|
||||
endIndex = i
|
||||
break
|
||||
return {
|
||||
reset: (list, container) => {
|
||||
listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
|
||||
if (!list?.length) return [];
|
||||
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
||||
const style = window.getComputedStyle(container);
|
||||
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
||||
return list.slice(0, Math.min(30, size));
|
||||
},
|
||||
init: (list, refreshCallback) => {
|
||||
if (listInited) return;
|
||||
const size = list.length, defaultHeight = avg.get() || 32;
|
||||
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
|
||||
for (let i = 0; i < size; i += groupItemCount) {
|
||||
let sum = 0;
|
||||
for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
|
||||
groupHeights.set(i, sum);
|
||||
}
|
||||
currentTop += h
|
||||
listInited = true; refreshCallback();
|
||||
},
|
||||
update: (absoluteIndex, node) => {
|
||||
if (itemMarginTop === null) {
|
||||
const style = window.getComputedStyle(node);
|
||||
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
|
||||
}
|
||||
if (absoluteIndex === 0) topMargin = itemMarginTop;
|
||||
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
|
||||
const oldHeight = itemHeights.get(absoluteIndex);
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
|
||||
const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
|
||||
}
|
||||
},
|
||||
calc: (container, list) => {
|
||||
if (!listInited || !list) return null;
|
||||
const size = list.length, visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32));
|
||||
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (status === 0) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh && prev + gh < container.scrollTop) { prev += gh; i += Math.min(groupItemCount, size - i) - 1; }
|
||||
else {
|
||||
const ih = itemHeights.get(i);
|
||||
if (prev + ih < container.scrollTop) prev += ih;
|
||||
else {
|
||||
status = 1; let visibleStartIndex = Math.max(0, i);
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
||||
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
|
||||
i = listEndIndex - 1; for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
|
||||
}
|
||||
}
|
||||
} else if (status === 1) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh) { post += gh; i += groupItemCount - 1; } else post += itemHeights.get(i);
|
||||
}
|
||||
}
|
||||
return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('List', container => {
|
||||
container.mode = container.getAttribute('mode') || 'normal'
|
||||
container.fast = container.hasAttribute('fast')
|
||||
container.collapsible = container.hasAttribute('collapsible')
|
||||
|
||||
const defaultSets = {
|
||||
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
||||
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
||||
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
||||
}
|
||||
container.collapsed = NewState({})
|
||||
|
||||
const updateFlatList = () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const list = container.state.list || [], flatList = []
|
||||
if (container.mode === 'group') {
|
||||
const itemMap = {}
|
||||
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
(container.state.groups || []).forEach(group => {
|
||||
flatList.push({ type: 'group', ...group })
|
||||
const items = itemMap[group[container.groupidfield]]
|
||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
})
|
||||
} else if (container.mode === 'tree') {
|
||||
const childrenMap = {}
|
||||
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const traverse = (items, level, parents) => items.forEach(item => {
|
||||
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
||||
const isCollapsed = container.collapsed[id]
|
||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
container.state._flatList = flatList
|
||||
}
|
||||
|
||||
if (startIndex === -1) startIndex = 0
|
||||
container.state.renderedList = list.slice(startIndex, endIndex).map((item, i) => ({ ...item, _index: startIndex + i }))
|
||||
container.state.__watch('list', updateFlatList)
|
||||
const vs = container.fast ? VirtualScroll() : null
|
||||
container.state._renderedList = []
|
||||
|
||||
const prevH = list.slice(0, startIndex).reduce((s, item) => s + (itemHeights.get(item.id) || itemHeight), 0)
|
||||
const postH = list.slice(endIndex).reduce((s, item) => s + (itemHeights.get(item.id) || itemHeight), 0)
|
||||
|
||||
const prev = container.querySelector('.list-spacer-prev')
|
||||
const post = container.querySelector('.list-spacer-post')
|
||||
if (prev) prev.style.height = prevH + 'px'
|
||||
if (post) post.style.height = postH + 'px'
|
||||
container.refresh = () => {
|
||||
if (!container.fast) return
|
||||
const res = vs.calc(container, container.state._flatList)
|
||||
if (res) {
|
||||
container.state.prevHeight = res.prevHeight
|
||||
container.state.postHeight = res.postHeight
|
||||
container.state._listStartIndex = res.listStartIndex
|
||||
container.state._renderedList = res.renderedList
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', update)
|
||||
container.state.__watch('list', update)
|
||||
Promise.resolve().then(update)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="fast-list-root overflow-auto h-100">
|
||||
<div class="list-spacer-prev"></div>
|
||||
<div class="list-container">
|
||||
<template $each="this.state.renderedList" key="id">
|
||||
<div slot-id="item"></div>
|
||||
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state._listStartIndex || 0), node) }
|
||||
|
||||
container.state.__watch('_flatList', flatList => {
|
||||
if (container.fast) {
|
||||
container.state._listStartIndex = 0
|
||||
container.state._renderedList = vs.reset(flatList, container) || []
|
||||
requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
|
||||
} else container.state._renderedList = flatList
|
||||
})
|
||||
|
||||
container.selectItem = (item, index) => {
|
||||
if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
||||
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state._listStartIndex || 0) : 0) } }))
|
||||
}
|
||||
container.selectGroup = (item, index) => {
|
||||
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
||||
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
||||
}
|
||||
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
||||
|
||||
updateFlatList()
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
|
||||
<div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
|
||||
<template slot-id="item" $each="this.state?._renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||
<template $if="item.type === 'group'">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="list-spacer-post"></div>
|
||||
</template>
|
||||
<div $if="this.fast && (this.state?.postHeight || 0) > 0" $style="height:\${this.state?.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
global.VirtualScroll = VirtualScroll;
|
||||
|
||||
})(globalThis);
|
||||
|
||||
76
src/nav.js
76
src/nav.js
@ -1,48 +1,36 @@
|
||||
// src/nav.js
|
||||
(function(global) {
|
||||
const { Component, Hash, Util } = global;
|
||||
import { Component, Hash, Util } from '@web/state'
|
||||
|
||||
Component.register('Nav', container => {
|
||||
container.state.items = []
|
||||
container.state.activeId = Hash.nav
|
||||
container.state.__watch('activeId', v => Hash.nav = v)
|
||||
window.addEventListener('hashchange', () => container.state.activeId = Hash.nav)
|
||||
|
||||
container.select = (id) => {
|
||||
container.state.activeId = id
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: id }))
|
||||
Component.register('Nav', container => {
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<template $each="this.state.items" as="item">
|
||||
<li class="nav-item">
|
||||
<a $class="nav-link \${this.state.activeId === item.id ? 'active' : ''}"
|
||||
href="#" $onclick="this.select(item.id)">
|
||||
<i $if="item.icon" $class="bi bi-\${item.icon} me-2"></i>
|
||||
<span $text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="navbar navbar-expand bg-body-secondary px-3 pb-0 border-bottom align-items-center">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" class="me-2" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} me-2"></i>
|
||||
<span $if="this.state.brand.label" class="me-2" $text="this.state.brand.label"></span>
|
||||
<div class="ms-2"></div>
|
||||
<div $each="this.state.list" $class="navbar-nav text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('Breadcrumb', container => {
|
||||
container.state.items = []
|
||||
}, Util.makeDom(/*html*/`
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<template $each="this.state.items" as="item">
|
||||
<li $class="breadcrumb-item \${index === (this.state.items || []).length - 1 ? 'active' : ''}">
|
||||
<template $if="index < (this.state.items || []).length - 1">
|
||||
<a href="#" $onclick="this.dispatchEvent(new CustomEvent('select', {detail: item}))" $text="item.label"></a>
|
||||
</template>
|
||||
<template $if="index === (this.state.items || []).length - 1">
|
||||
<span $text="item.label"></span>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
`))
|
||||
|
||||
})(globalThis);
|
||||
|
||||
76
src/ui.js
76
src/ui.js
@ -1,10 +1,8 @@
|
||||
// src/ui.js
|
||||
(function(global) {
|
||||
const { Component, Util, $ } = global;
|
||||
import { Component, Util, $ } from '@web/state'
|
||||
|
||||
const UI = {};
|
||||
export const UI = {}
|
||||
|
||||
Component.register('Modal', container => {
|
||||
Component.register('Modal', container => {
|
||||
container.modal = new bootstrap.Modal(container)
|
||||
container.addEventListener('bind', e => {
|
||||
e.detail ? container.modal.show() : container.modal.hide()
|
||||
@ -14,41 +12,37 @@
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
|
||||
})
|
||||
Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, Util.makeDom(/*html*/`
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div slot-id="header" class="modal-header py-2 px-3 bg-light">
|
||||
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'primary'}">
|
||||
<div slot-id="header" class="modal-header">
|
||||
<h6 class="modal-title" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body p-3"></div>
|
||||
<div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>
|
||||
<div slot-id="body" class="modal-body"></div>
|
||||
<div slot-id="footer" class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
|
||||
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<template $if="this.state?.title">
|
||||
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
</template>
|
||||
<div slot-id="body" class="modal-body p-4"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer py-2 px-3 bg-light">
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</template>
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'primary'}">
|
||||
<div $if="this.state?.title" class="modal-header" $text="this.state?.title"></div>
|
||||
<div slot-id="body" class="modal-body"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" class="btn btn-sm btn-\${index === buttons.length - 1 ? (type && type !== 'body' ? type : 'primary') : 'secondary'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
let _dialogCount = 0
|
||||
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
const d = document.body.appendChild(document.createElement('Dialog'))
|
||||
d.style.zIndex = 2000 + ++_dialogCount
|
||||
Promise.resolve().then(() => {
|
||||
@ -62,16 +56,16 @@
|
||||
d.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
UI.alert = function (message, options = {}) {
|
||||
UI.alert = function (message, options = {}) {
|
||||
return UI.showDialog({ message, ...options })
|
||||
}
|
||||
UI.confirm = function (message, options = {}) {
|
||||
}
|
||||
UI.confirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('Toast', container => {
|
||||
Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
Util.copyFunction(container, container.toast, 'show', 'hide')
|
||||
container.addEventListener('show.bs.toast', () => {
|
||||
@ -88,39 +82,31 @@
|
||||
container.addEventListener('mouseleave', startTimer)
|
||||
}
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body rounded p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<template $if="this.state?.left !== undefined">
|
||||
<span class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</template>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
|
||||
|
||||
UI.toast = function (message, options = {}) {
|
||||
UI.toast = function (message, options = {}) {
|
||||
const delay = options.delay ?? 5000
|
||||
const t = document.createElement('Toast')
|
||||
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'primary', message, buttons: options.buttons || [] }
|
||||
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
|
||||
Promise.resolve().then(() => t.show())
|
||||
}
|
||||
}
|
||||
|
||||
UI.toastConfirm = function (message, options = {}) {
|
||||
UI.toastConfirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
global.UI = UI;
|
||||
|
||||
})(globalThis);
|
||||
}
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"8a84b43f13b676ea22b7-4f4df3778b9a6eb1af05",
|
||||
"3779845d6d162ffd6a31-a0891c7cdc5018d2d965",
|
||||
"3b536bb397f2008773c1-4debec93cdb0fb4b728c",
|
||||
"e444dae6bf10150e2e04-c45d1b6ee1ed839393fc",
|
||||
"2812e09a1ea99f189232-a499b1acb367a0cbe09c"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: all.spec.js >> base project comprehensive tests and scrolling benchmarks
|
||||
- Location: test/all.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(received).toBeGreaterThan(expected)
|
||||
|
||||
Expected: > 0
|
||||
Received: 0
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- heading "All Tests Passed 🎉" [level=1] [ref=e3]
|
||||
- generic [ref=e4]:
|
||||
- button "Fast List" [ref=e5] [cursor=pointer]
|
||||
- button "Fast Grouped" [ref=e6] [cursor=pointer]
|
||||
- button "Fast Tree" [ref=e7] [cursor=pointer]
|
||||
- button "Normal Tree" [ref=e8] [cursor=pointer]
|
||||
- button "AutoForm Controls" [ref=e9] [cursor=pointer]
|
||||
- heading "Fast List (Variable Height)" [level=5] [ref=e12]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | test('base project comprehensive tests and scrolling benchmarks', async ({ page }) => {
|
||||
4 | test.setTimeout(60000);
|
||||
5 | page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
6 | page.on('pageerror', err => console.log('BROWSER EXCEPTION:', err.message, err.stack));
|
||||
7 | await page.goto('http://127.0.0.1:8082/test/index.html');
|
||||
8 |
|
||||
9 | // Wait for testStatus to be set (includes basic unit tests and scrolling refresh test)
|
||||
10 | await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 60000 });
|
||||
11 |
|
||||
12 | const status = await page.evaluate(() => window.testStatus);
|
||||
13 | expect(status).toBe('passed');
|
||||
14 |
|
||||
15 | // Simulate real scrolling and check dynamic rendering for FastList
|
||||
16 | console.log('Simulating real scroll on FastList...');
|
||||
17 | const scrollInfo = await page.evaluate(async () => {
|
||||
18 | window.switchTab?.('ll');
|
||||
19 | await new Promise(r => setTimeout(r, 200)); // wait for render
|
||||
20 | const el = document.getElementById('ll');
|
||||
21 | const results = [];
|
||||
22 |
|
||||
23 | const getRenderedCount = () => el.querySelectorAll('.border-primary').length;
|
||||
24 |
|
||||
25 | results.push({
|
||||
26 | id: 'initial',
|
||||
27 | scrollTop: el.scrollTop,
|
||||
28 | scrollHeight: el.scrollHeight,
|
||||
29 | clientHeight: el.clientHeight,
|
||||
30 | renderedCount: getRenderedCount(),
|
||||
31 | renderedListLen: el.state.renderedList?.length
|
||||
32 | });
|
||||
33 |
|
||||
34 | // Scroll to middle
|
||||
35 | el.scrollTop = 50000;
|
||||
36 | el.refresh?.();
|
||||
37 | await new Promise(r => setTimeout(r, 500));
|
||||
38 | const midCount = getRenderedCount();
|
||||
39 | const midHtml = el.innerHTML.length;
|
||||
40 | results.push({
|
||||
41 | id: 'deep-middle',
|
||||
42 | scrollTop: el.scrollTop,
|
||||
43 | renderedCount: midCount,
|
||||
44 | htmlLen: midHtml,
|
||||
45 | prevHeight: el.state.prevHeight,
|
||||
46 | postHeight: el.state.postHeight,
|
||||
47 | renderedListLen: el.state._renderedList?.length
|
||||
48 | });
|
||||
49 |
|
||||
50 | // Scroll to end
|
||||
51 | el.scrollTop = el.scrollHeight;
|
||||
52 | el.refresh?.();
|
||||
53 | await new Promise(r => setTimeout(r, 500));
|
||||
54 | results.push({
|
||||
55 | id: 'end',
|
||||
56 | scrollTop: el.scrollTop,
|
||||
57 | renderedCount: getRenderedCount(),
|
||||
58 | scrollHeight: el.scrollHeight,
|
||||
59 | prevHeight: el.state.prevHeight,
|
||||
60 | postHeight: el.state.postHeight
|
||||
61 | });
|
||||
62 |
|
||||
63 | return results;
|
||||
64 | });
|
||||
65 |
|
||||
66 | console.log('Scroll Details:', JSON.stringify(scrollInfo, null, 2));
|
||||
> 67 | expect(scrollInfo[0].renderedCount).toBeGreaterThan(0);
|
||||
| ^ Error: expect(received).toBeGreaterThan(expected)
|
||||
68 | expect(scrollInfo[1].scrollTop).toBeGreaterThan(0);
|
||||
69 |
|
||||
70 | const bench = await page.evaluate(() => window.benchResults);
|
||||
71 | if (bench) {
|
||||
72 | Object.entries(bench).forEach(([name, time]) => {
|
||||
73 | console.log(`BENCHMARK [${name}]: ${time.toFixed(2)}ms`);
|
||||
74 | });
|
||||
75 | }
|
||||
76 | });
|
||||
77 |
|
||||
```
|
||||
@ -1,119 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: deep_dump.spec.js >> Capability demo page deep DOM dump
|
||||
- Location: test/deep_dump.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(received).toBeGreaterThan(expected)
|
||||
|
||||
Expected: > 0
|
||||
Received: 0
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- navigation [ref=e4]
|
||||
- generic [ref=e6]:
|
||||
- generic [ref=e7]:
|
||||
- heading "项目概览" [level=2] [ref=e8]
|
||||
- button " 切换主题" [ref=e10] [cursor=pointer]:
|
||||
- generic [ref=e11]:
|
||||
- text: 切换主题
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- heading "Apigo Base Mega Demo" [level=1] [ref=e14]
|
||||
- paragraph [ref=e15]: 点击左侧菜单查看不同组件的能力展示
|
||||
- generic [ref=e16]:
|
||||
- heading "核心原子能力验证 (State singleton)" [level=4] [ref=e17]
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e20]:
|
||||
- generic [ref=e21]: 1. $text & 样式绑定
|
||||
- generic [ref=e22]:
|
||||
- paragraph [ref=e23]: Synchronous State Ready
|
||||
- button "切换颜色" [ref=e24] [cursor=pointer]
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]: 2. $if 显式模板判断
|
||||
- generic [ref=e28]:
|
||||
- generic [ref=e29]: 已激活 ($if="true")
|
||||
- button "切换显示状态" [ref=e30] [cursor=pointer]
|
||||
- generic [ref=e32]:
|
||||
- generic [ref=e33]: 3. $each 循环渲染
|
||||
- generic [ref=e35]:
|
||||
- generic [ref=e36]: Alpha
|
||||
- generic [ref=e37]: Beta
|
||||
- generic [ref=e38]: Gamma
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: 4. $if 嵌套 $each
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]: Red
|
||||
- generic [ref=e45]: Green
|
||||
- generic [ref=e46]: Blue
|
||||
- button "切换外层容器" [ref=e47] [cursor=pointer]
|
||||
- generic [ref=e49]:
|
||||
- generic [ref=e50]:
|
||||
- text: 5. $each 嵌套 $if (奇偶成员过滤)
|
||||
- button "仅看奇数项" [ref=e51] [cursor=pointer]
|
||||
- generic [ref=e53]:
|
||||
- generic [ref=e54]:
|
||||
- generic [ref=e55]:
|
||||
- text: Alice (1)
|
||||
- generic [ref=e56]:
|
||||
- generic [ref=e57]:
|
||||
- text: Bob (2)
|
||||
- generic [ref=e58]:
|
||||
- generic [ref=e59]:
|
||||
- text: Charlie (3)
|
||||
- generic [ref=e60]:
|
||||
- generic [ref=e61]:
|
||||
- text: David (4)
|
||||
- generic [ref=e62]:
|
||||
- generic [ref=e63]:
|
||||
- text: Eve (5)
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | test('Capability demo page deep DOM dump', async ({ page }) => {
|
||||
4 | page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
5 |
|
||||
6 | await page.goto('http://localhost:5173/test/capability.html');
|
||||
7 | await page.waitForTimeout(3000);
|
||||
8 |
|
||||
9 | const dump = await page.evaluate(() => {
|
||||
10 | const nav = document.getElementById('mainNav');
|
||||
11 | const form = document.getElementById('demoForm');
|
||||
12 | return {
|
||||
13 | nav: {
|
||||
14 | tagName: nav?.tagName,
|
||||
15 | childCount: nav?.children.length,
|
||||
16 | innerHTML: nav?.innerHTML
|
||||
17 | },
|
||||
18 | form: {
|
||||
19 | tagName: form?.tagName,
|
||||
20 | childCount: form?.children.length,
|
||||
21 | innerHTML: form?.innerHTML
|
||||
22 | }
|
||||
23 | };
|
||||
24 | });
|
||||
25 |
|
||||
26 | console.log('Deep DOM Dump:', JSON.stringify(dump, null, 2));
|
||||
27 |
|
||||
> 28 | expect(dump.nav.childCount).toBeGreaterThan(0);
|
||||
| ^ Error: expect(received).toBeGreaterThan(expected)
|
||||
29 | });
|
||||
30 |
|
||||
```
|
||||
@ -1,41 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: mega_verify.spec.js >> Empirical Mega Verification
|
||||
- Location: test/mega_verify.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.evaluate: TypeError: Cannot read properties of null (reading 'querySelectorAll')
|
||||
at eval (eval at evaluate (:302:30), <anonymous>:3:27)
|
||||
at UtilityScript.evaluate (<anonymous>:304:16)
|
||||
at UtilityScript.<anonymous> (<anonymous>:1:44)
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- heading "AutoForm Mega Test & Function Showcase" [level=2] [ref=e3]
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e7]: 1. Vertical Mode
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: 2. Responsive Horizontal
|
||||
- generic [ref=e13]: Try shrinking the window!
|
||||
- generic [ref=e16]: 2b. Forced Horizontal
|
||||
- generic [ref=e18]:
|
||||
- heading "3. Inline Mode Scenarios" [level=4] [ref=e19]
|
||||
- generic [ref=e20]:
|
||||
- heading "Toolbar (No label, with action)" [level=6] [ref=e22]
|
||||
- heading "Compact Config (With labels, no button)" [level=6] [ref=e25]
|
||||
- generic [ref=e27]:
|
||||
- generic [ref=e28]: Live Data Sync Debug
|
||||
- generic [ref=e30]: "{ \"t\": \"Apigo\", \"s\": \"1\", \"c\": [ \"Apple\" ], \"sw\": false, \"cp\": \"#0d6efd\", \"range\": \"2026-01-01\", \"rangeEnd\": \"2026-12-31\" }"
|
||||
```
|
||||
@ -1,71 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: verify_stress.spec.js >> Stress test: Virtual List should handle 10,000 items with dynamic height
|
||||
- Location: test/verify_stress.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Test timeout of 60000ms exceeded.
|
||||
```
|
||||
|
||||
```
|
||||
Error: page.waitForFunction: Test timeout of 60000ms exceeded.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- heading "1. Standard List (Normal)" [level=5] [ref=e4]
|
||||
- 'heading "2. Group List (Mode: Group)" [level=5] [ref=e7]'
|
||||
- 'heading "3. Tree List (Mode: Tree + Collapsible)" [level=5] [ref=e10]'
|
||||
- heading "4. FAST Virtual List (10,000 Items + Dynamic Height)" [level=5] [ref=e13]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | test('Stress test: Virtual List should handle 10,000 items with dynamic height', async ({ page }) => {
|
||||
4 | test.setTimeout(60000);
|
||||
5 | page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
6 |
|
||||
7 | await page.goto('http://localhost:8082/test/list_test.html');
|
||||
8 |
|
||||
9 | const listFast = page.locator('#listFast');
|
||||
10 | await expect(listFast).toBeVisible();
|
||||
> 11 | await page.waitForFunction(() => document.querySelectorAll('#listFast .list-group-item').length > 0);
|
||||
| ^ Error: page.waitForFunction: Test timeout of 60000ms exceeded.
|
||||
12 |
|
||||
13 | // Check initial state
|
||||
14 | const scrollTop = await listFast.evaluate(e => e.scrollTop);
|
||||
15 | const scrollHeight = await listFast.evaluate(e => e.scrollHeight);
|
||||
16 | const clientHeight = await listFast.evaluate(e => e.clientHeight);
|
||||
17 | console.log(`Initial: scrollTop=${scrollTop}, scrollHeight=${scrollHeight}, clientHeight=${clientHeight}`);
|
||||
18 |
|
||||
19 | // Attach event listener to see if scroll fires
|
||||
20 | await listFast.evaluate(e => {
|
||||
21 | e.addEventListener('scroll', () => console.log('SCROLL EVENT FIRED! new scrollTop:', e.scrollTop));
|
||||
22 | });
|
||||
23 |
|
||||
24 | // Scroll to the very end
|
||||
25 | await listFast.evaluate(e => e.scrollTop = e.scrollHeight);
|
||||
26 | await page.waitForTimeout(1000);
|
||||
27 |
|
||||
28 | const finalScrollTop = await listFast.evaluate(e => e.scrollTop);
|
||||
29 | console.log(`Final: scrollTop=${finalScrollTop}`);
|
||||
30 |
|
||||
31 | const lastItemText = await listFast.locator('.list-group-item').last().textContent();
|
||||
32 | console.log('Last rendered item text:', lastItemText);
|
||||
33 | expect(lastItemText).toContain('Virtual Item 10000');
|
||||
34 | });
|
||||
35 |
|
||||
```
|
||||
@ -1,72 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: verify_style.spec.js >> Check styles and globals
|
||||
- Location: test/verify_style.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Test timeout of 30000ms exceeded.
|
||||
```
|
||||
|
||||
```
|
||||
Error: locator.evaluate: Test timeout of 30000ms exceeded.
|
||||
Call log:
|
||||
- waiting for locator('#formH form')
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- heading "AutoForm Mega Test & Function Showcase" [level=2] [ref=e3]
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e7]: 1. Vertical Mode
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e11]:
|
||||
- generic [ref=e12]: 2. Responsive Horizontal
|
||||
- generic [ref=e13]: Try shrinking the window!
|
||||
- generic [ref=e16]: 2b. Forced Horizontal
|
||||
- generic [ref=e18]:
|
||||
- heading "3. Inline Mode Scenarios" [level=4] [ref=e19]
|
||||
- generic [ref=e20]:
|
||||
- heading "Toolbar (No label, with action)" [level=6] [ref=e22]
|
||||
- heading "Compact Config (With labels, no button)" [level=6] [ref=e25]
|
||||
- generic [ref=e27]:
|
||||
- generic [ref=e28]: Live Data Sync Debug
|
||||
- generic [ref=e30]: "{ \"t\": \"Apigo\", \"s\": \"1\", \"c\": [ \"Apple\" ], \"sw\": false, \"cp\": \"#0d6efd\", \"range\": \"2026-01-01\", \"rangeEnd\": \"2026-12-31\" }"
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from '@playwright/test';
|
||||
2 |
|
||||
3 | test('Check styles and globals', async ({ page }) => {
|
||||
4 | await page.goto('http://localhost:8082/test/form_test.html');
|
||||
5 |
|
||||
6 | // Check if auto-grid-form is grid (horizontal mode)
|
||||
7 | const formH = page.locator('#formH form');
|
||||
> 8 | const display = await formH.evaluate(el => window.getComputedStyle(el).display);
|
||||
| ^ Error: locator.evaluate: Test timeout of 30000ms exceeded.
|
||||
9 | console.log('formH display:', display);
|
||||
10 | expect(display).toBe('grid');
|
||||
11 |
|
||||
12 | // Get color picker input to check height
|
||||
13 | const colorInput = page.locator('#formH input[type="color"]');
|
||||
14 | if (await colorInput.count() > 0) {
|
||||
15 | const height = await colorInput.evaluate(el => window.getComputedStyle(el).height);
|
||||
16 | console.log('colorInput height:', height);
|
||||
17 | // It shouldn't be very small (like 0 or 2px)
|
||||
18 | expect(parseInt(height)).toBeGreaterThan(20);
|
||||
19 | }
|
||||
20 | });
|
||||
21 |
|
||||
```
|
||||
@ -3,7 +3,6 @@ import { test, expect } from '@playwright/test';
|
||||
test('base project comprehensive tests and scrolling benchmarks', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
page.on('pageerror', err => console.log('BROWSER EXCEPTION:', err.message, err.stack));
|
||||
await page.goto('http://127.0.0.1:8082/test/index.html');
|
||||
|
||||
// Wait for testStatus to be set (includes basic unit tests and scrolling refresh test)
|
||||
@ -15,8 +14,6 @@ test('base project comprehensive tests and scrolling benchmarks', async ({ page
|
||||
// Simulate real scrolling and check dynamic rendering for FastList
|
||||
console.log('Simulating real scroll on FastList...');
|
||||
const scrollInfo = await page.evaluate(async () => {
|
||||
window.switchTab?.('ll');
|
||||
await new Promise(r => setTimeout(r, 200)); // wait for render
|
||||
const el = document.getElementById('ll');
|
||||
const results = [];
|
||||
|
||||
@ -32,32 +29,21 @@ test('base project comprehensive tests and scrolling benchmarks', async ({ page
|
||||
});
|
||||
|
||||
// Scroll to middle
|
||||
el.scrollTop = 50000;
|
||||
el.refresh?.();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const midCount = getRenderedCount();
|
||||
const midHtml = el.innerHTML.length;
|
||||
el.scrollTop = 2500;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
results.push({
|
||||
id: 'deep-middle',
|
||||
id: 'middle',
|
||||
scrollTop: el.scrollTop,
|
||||
renderedCount: midCount,
|
||||
htmlLen: midHtml,
|
||||
prevHeight: el.state.prevHeight,
|
||||
postHeight: el.state.postHeight,
|
||||
renderedListLen: el.state._renderedList?.length
|
||||
renderedCount: getRenderedCount()
|
||||
});
|
||||
|
||||
// Scroll to end
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.refresh?.();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
results.push({
|
||||
id: 'end',
|
||||
scrollTop: el.scrollTop,
|
||||
renderedCount: getRenderedCount(),
|
||||
scrollHeight: el.scrollHeight,
|
||||
prevHeight: el.state.prevHeight,
|
||||
postHeight: el.state.postHeight
|
||||
renderedCount: getRenderedCount()
|
||||
});
|
||||
|
||||
return results;
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Capability demo page atomic tests verification', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
|
||||
await page.goto('/test/capability.html');
|
||||
// Wait for the framework to initialize and finish initial renders
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const testResults = await page.evaluate(() => {
|
||||
const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
|
||||
const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
|
||||
|
||||
return {
|
||||
textBinding: getTexts('.test-title-node'),
|
||||
textColor: getColors('.test-title-node'),
|
||||
ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
|
||||
ifFalse: document.querySelector('.alert-danger')?.textContent.trim(),
|
||||
eachItems: getTexts('.list-group-item'),
|
||||
nestedEachInIf: getTexts('.badge.bg-secondary'),
|
||||
nestedIfInEach: getTexts('.p-2.border.rounded.bg-white.shadow-sm')
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
|
||||
|
||||
expect(testResults.textBinding).toContain('Synchronous State Ready');
|
||||
expect(testResults.ifTrue).toBe('已激活 ($if="true")');
|
||||
expect(testResults.ifFalse).toBeUndefined();
|
||||
expect(testResults.eachItems).toHaveLength(3);
|
||||
expect(testResults.nestedEachInIf).toHaveLength(3);
|
||||
expect(testResults.nestedIfInEach).toHaveLength(5); // All members initially visible
|
||||
});
|
||||
@ -1,38 +1,73 @@
|
||||
// test/base.test.js
|
||||
window.runTests = async function() {
|
||||
import { HTTP, UI, State } from '@web/base';
|
||||
|
||||
export async function runTests() {
|
||||
console.log('Starting comprehensive Base.js tests...');
|
||||
|
||||
// 1. HTTP Test
|
||||
console.log('Testing HTTP (local check)...');
|
||||
if (typeof HTTP === 'undefined') throw new Error('Global HTTP not found');
|
||||
if (typeof HTTP.request !== 'function') throw new Error('HTTP.request missing');
|
||||
|
||||
// 2. UI Test
|
||||
// 2. State Test
|
||||
console.log('Testing State...');
|
||||
State.exitBlocks = 1;
|
||||
if (State.exitBlocks !== 1) throw new Error('State update failed');
|
||||
State.exitBlocks = 0;
|
||||
|
||||
// 3. UI Namespace Test
|
||||
console.log('Testing UI...');
|
||||
if (typeof UI === 'undefined') throw new Error('Global UI not found');
|
||||
if (typeof UI.alert !== 'function') throw new Error('UI.alert not found');
|
||||
if (typeof UI.alert !== 'function') throw new Error('UI.alert missing');
|
||||
if (typeof UI.toast !== 'function') throw new Error('UI.toast missing');
|
||||
|
||||
// 3. Component Check
|
||||
console.log('Testing Components...');
|
||||
if (!Component.exists('Modal')) throw new Error('Modal component not registered');
|
||||
if (!Component.exists('AutoForm')) throw new Error('AutoForm component not registered');
|
||||
if (!Component.exists('FastList')) throw new Error('FastList component not registered');
|
||||
// 4. API Component Test
|
||||
console.log('Testing API Component...');
|
||||
const api = document.createElement('API');
|
||||
document.body.appendChild(api);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
api.request.url = 'https://jsonplaceholder.typicode.com/todos/1';
|
||||
const apiResp = await api.do();
|
||||
if (!apiResp.ok) throw new Error('API component request failed');
|
||||
api.remove();
|
||||
|
||||
// 4. State Integration
|
||||
console.log('Testing State integration...');
|
||||
if (typeof State === 'undefined') throw new Error('Global State not found');
|
||||
// 5. AutoForm & TagsInput Test
|
||||
console.log('Testing AutoForm...');
|
||||
const form = document.createElement('AutoForm');
|
||||
document.body.appendChild(form);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
form.state.schema = [
|
||||
{ name: 'tags', type: 'TagsInput', label: '标签' },
|
||||
{ name: 'name', type: 'text', label: '姓名' }
|
||||
];
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
if (!form.querySelector('TagsInput')) throw new Error('TagsInput not rendered in AutoForm');
|
||||
form.remove();
|
||||
|
||||
// 5. VirtualScroll logic
|
||||
console.log('Testing VirtualScroll...');
|
||||
if (typeof VirtualScroll === 'undefined') throw new Error('Global VirtualScroll not found');
|
||||
// 6. List Components Basic Verification
|
||||
console.log('Verifying List Components...');
|
||||
const { Component } = await import('@web/state');
|
||||
console.log('FastList exists:', Component.exists('FastList'));
|
||||
|
||||
const listIds = ['ll', 'gl', 'tt', 'ct'];
|
||||
for (const id of listIds) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`Component #${id} not found`);
|
||||
console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName));
|
||||
if (!el.state.list || el.state.list.length === 0) throw new Error(`Component #${id} list data not bound`);
|
||||
}
|
||||
|
||||
// 7. Benchmarks
|
||||
console.log('Recording list benchmarks...');
|
||||
window.benchResults = {};
|
||||
|
||||
// Benchmark placeholders (visual verification required via UI)
|
||||
const measure = async (id, name) => {
|
||||
const list = document.getElementById(id);
|
||||
if (!list) return;
|
||||
const el = document.getElementById(id);
|
||||
const start = performance.now();
|
||||
switchTab(id);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
console.log(`BENCHMARK [${name}]: ${performance.now() - start}ms, Items: ${list.state?.renderedList?.length}`);
|
||||
el.scrollTop = 5000;
|
||||
el.refresh?.();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const time = performance.now() - start;
|
||||
window.benchResults[name] = time;
|
||||
console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`);
|
||||
console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`);
|
||||
};
|
||||
|
||||
await measure('ll', 'FastList');
|
||||
|
||||
@ -1,297 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apigo Base 能力展示 (Mega Demo - Sync)</title>
|
||||
<!-- 引入 Bootstrap 5.3 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<!-- 核心同步脚本加载 (使用本地副本消灭路径风险) -->
|
||||
<script src="./lib/state.js"></script>
|
||||
<script src="./lib/bootstrap.js"></script>
|
||||
<script src="./lib/base.js"></script>
|
||||
|
||||
<style>
|
||||
body { height: 100vh; overflow: hidden; }
|
||||
.main-container { height: 100%; display: flex; }
|
||||
.sidebar { min-width: 200px; max-width: 500px; display: flex; flex-direction: column; }
|
||||
.content { flex: 1; overflow: auto; padding: 20px; }
|
||||
.section-card { margin-bottom: 30px; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
|
||||
.section-header { background: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #dee2e6; font-weight: bold; }
|
||||
.section-body { padding: 15px; }
|
||||
[data-bs-theme="dark"] .section-header { background: #343a40; border-bottom-color: #495057; }
|
||||
[data-bs-theme="dark"] .section-card { border-color: #495057; }
|
||||
.btn-xs { padding: 0.1rem 0.3rem; font-size: 0.75rem; }
|
||||
</style>
|
||||
<script>
|
||||
// 1. 静态结构数据 (用于组件初始化,必须在 body 解析前就绪)
|
||||
window.brand = { icon: 'cpu', label: 'Base 能力展示' };
|
||||
window.navList = [
|
||||
{ type: 'button', name: 'welcome', label: '项目概览', icon: 'house' },
|
||||
{ type: 'button', name: 'forms', label: '表单与控件', icon: 'input-cursor-text' },
|
||||
{ type: 'button', name: 'lists', label: '增强列表', icon: 'list-ul' },
|
||||
{ type: 'button', name: 'ui', label: '交互反馈', icon: 'chat-square-dots' },
|
||||
{ type: 'button', name: 'api', label: '网络通讯', icon: 'cloud-arrow-down' }
|
||||
];
|
||||
window.formSchema = [
|
||||
{ name: 'username', label: '用户名', type: 'text', placeholder: '请输入用户名', setting: { required: true } },
|
||||
{ name: 'password', label: '密码', type: 'password' },
|
||||
{ name: 'dateRange', label: '日期范围', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
|
||||
{ name: 'endDate', label: '结束日期', type: 'date', hidden: true },
|
||||
{ name: 'theme', label: '主题色', type: 'ColorPicker' },
|
||||
{ name: 'icon', label: '图标', type: 'IconPicker' },
|
||||
{ name: 'tags', label: '标签', type: 'TagsInput' },
|
||||
{ name: 'role', label: '角色', type: 'select', options: [{ label: '管理员', value: 'admin' }, { label: '普通用户', value: 'user' }] },
|
||||
{ name: 'gender', label: '性别', type: 'checkbox', options: ['男', '女', '保密'] },
|
||||
{ name: 'notify', label: '接收通知', type: 'switch' }
|
||||
];
|
||||
|
||||
// 2. 初始化全局 State 初始值
|
||||
// 使用 window.State 因为 state.js 已经同步加载完毕
|
||||
Object.assign(window.State, {
|
||||
currentSection: 'welcome',
|
||||
testTitle: 'Synchronous State Ready',
|
||||
testShow: true,
|
||||
testColor: 'blue',
|
||||
testItems: ['Alpha', 'Beta', 'Gamma'],
|
||||
showOuter: true,
|
||||
innerItems: ['Red', 'Green', 'Blue'],
|
||||
members: [
|
||||
{ id: 1, name: 'Alice (1)', odd: true },
|
||||
{ id: 2, name: 'Bob (2)', odd: false },
|
||||
{ id: 3, name: 'Charlie (3)', odd: true },
|
||||
{ id: 4, name: 'David (4)', odd: false },
|
||||
{ id: 5, name: 'Eve (5)', odd: true }
|
||||
],
|
||||
showOddOnly: false,
|
||||
listData: [],
|
||||
listGroups: [
|
||||
{ id: 'group1', label: '开发团队', icon: 'code-slash' },
|
||||
{ id: 'group2', label: '设计团队', icon: 'palette' }
|
||||
],
|
||||
listMode: 'tree',
|
||||
formData: { username: 'ApigoUser', theme: '#0d6efd', tags: ['Fast'] },
|
||||
apiResult: null
|
||||
});
|
||||
|
||||
if (!Hash.nav) Hash.nav = 'welcome';
|
||||
</script>
|
||||
</head>
|
||||
<body $data-bs-theme="LocalStorage.darkMode ? 'dark' : 'light'">
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 侧边栏导航 -->
|
||||
<div id="sidebar" class="sidebar bg-body-tertiary border-end" style="width: 260px;">
|
||||
<Nav id="mainNav" vertical class="flex-fill"
|
||||
$.state.brand="window.brand"
|
||||
$.state.list="window.navList"
|
||||
$onnav="State.currentSection = event.detail.item.name">
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽调节器 -->
|
||||
<Resizer target="sidebar" min="150" max="450"></Resizer>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content bg-body">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 $text="window.navList.find(s => s.name === State.currentSection)?.label || '欢迎'"></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="LocalStorage.darkMode = !LocalStorage.darkMode">
|
||||
<i class="bi" $class="LocalStorage.darkMode ? 'bi-sun' : 'bi-moon'"></i>
|
||||
切换主题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. 表单展示 -->
|
||||
<template $if="State.currentSection === 'forms'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">AutoForm & 扩展控件</div>
|
||||
<div class="section-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<AutoForm id="demoForm" vertical
|
||||
$.state.schema="window.formSchema"
|
||||
$.state.data="State.formData"
|
||||
$onsubmit="UI.toast('提交数据: ' + JSON.stringify(event.detail))">
|
||||
</AutoForm>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6>实时数据预览:</h6>
|
||||
<pre class="bg-light p-2 border rounded" $text="JSON.stringify(State.formData, null, 2)"></pre>
|
||||
<button class="btn btn-primary w-100" onclick="resetFormData()">重置数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 2. 列表展示 -->
|
||||
<template $if="State.currentSection === 'lists'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">List 增强列表 (虚拟滚动 + 树形)</div>
|
||||
<div class="section-body">
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'normal'">普通列表</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'group'">分组列表</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'tree'">树形列表</button>
|
||||
</div>
|
||||
|
||||
<div style="height: 500px;" class="border rounded overflow-hidden">
|
||||
<List id="demoList" fast collapsible auto-select class="h-100 overflow-auto"
|
||||
$mode="State.listMode"
|
||||
$.state.list="State.listData"
|
||||
$.state.groups="State.listGroups"
|
||||
$onitemclick="UI.toast('点击项目: ' + event.detail.item.label)">
|
||||
<template slot-id="item">
|
||||
<i class="bi me-2" $class="item.icon || 'bi-file-earmark'"></i>
|
||||
<span $text="item.label"></span>
|
||||
<span class="ms-auto badge bg-secondary" $text="item.id"></span>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 3. 交互展示 -->
|
||||
<template $if="State.currentSection === 'ui'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">UI 交互工具</div>
|
||||
<div class="section-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><button class="btn btn-info w-100" onclick="UI.toast('这是一个通知消息')">Toast (Info)</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-danger w-100" onclick="UI.toast('操作失败!', { type: 'danger' })">Toast (Danger)</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-success w-100" onclick="UI.toastConfirm('你确定要完成吗?').then(r => UI.toast(r ? '已确认' : '已取消'))">Toast Confirm</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-primary w-100" onclick="UI.alert('这是一个警告框')">UI.alert</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-warning w-100" onclick="UI.confirm('你确定要删除吗?').then(r => UI.toast(r ? '已点击确认' : '已点击取消'))">UI.confirm</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-dark w-100" onclick="showComplexDialog()">UI.showDialog</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-secondary w-100" onclick="testHTTP()">HTTP.get Test</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 4. 网络展示 -->
|
||||
<template $if="State.currentSection === 'api'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">API 声明式请求</div>
|
||||
<div class="section-body">
|
||||
<API id="userApi"
|
||||
$request="{ url: 'https://jsonplaceholder.typicode.com/users/1', method: 'GET' }"
|
||||
$onresponse="State.apiResult = event.detail.result"
|
||||
$onerror="UI.toast('API 请求失败: ' + event.detail.error, { type: 'danger' })">
|
||||
</API>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn btn-primary" onclick="userApi.do()">触发请求</button>
|
||||
<button class="btn btn-outline-secondary" onclick="State.apiResult = null">清空结果</button>
|
||||
</div>
|
||||
<h6>请求结果 (JSONPlaceholder):</h6>
|
||||
<pre class="bg-light p-2 border rounded" $text="State.apiResult ? JSON.stringify(State.apiResult, null, 2) : '等待请求...'"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 默认页 -->
|
||||
<template $if="State.currentSection === 'welcome'">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-rocket-takeoff text-primary" style="font-size: 5rem;"></i>
|
||||
<h1 class="mt-3">Apigo Base Mega Demo</h1>
|
||||
<p class="text-muted">点击左侧菜单查看不同组件的能力展示</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 p-4 border rounded bg-light">
|
||||
<h4 class="mb-4 border-bottom pb-2 text-start">核心原子能力验证 (State singleton)</h4>
|
||||
|
||||
<div class="row g-4 text-start">
|
||||
<!-- 1. $text & style binding -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">1. $text & 样式绑定</div>
|
||||
<div class="card-body text-center">
|
||||
<p class="fs-4 fw-bold test-title-node" $text="State.testTitle" $style="'color:' + State.testColor"></p>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.testColor = State.testColor === 'blue' ? 'green' : 'blue'">切换颜色</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. $if (True/False) -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">2. $if 显式模板判断</div>
|
||||
<div class="card-body text-center">
|
||||
<template $if="State.testShow">
|
||||
<div class="alert alert-success py-2">已激活 ($if="true")</div>
|
||||
</template>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="State.testShow = !State.testShow">切换显示状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. $each (Simple) -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">3. $each 循环渲染</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush border rounded">
|
||||
<template $each="State.testItems">
|
||||
<div class="list-group-item py-1" $text="item"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Nested each inside if -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">4. $if 嵌套 $each</div>
|
||||
<div class="card-body text-center">
|
||||
<template $if="State.showOuter">
|
||||
<div class="p-2 border rounded bg-white mb-2">
|
||||
<template $each="State.innerItems">
|
||||
<span class="badge bg-secondary me-1" $text="item"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.showOuter = !State.showOuter">切换外层容器</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Nested if inside each (Filter) -->
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
5. $each 嵌套 $if (奇偶成员过滤)
|
||||
<button class="btn btn-xs btn-primary py-0" onclick="State.showOddOnly = !State.showOddOnly" $text="State.showOddOnly ? '显示全部' : '仅看奇数项'"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<template $each="State.members">
|
||||
<template $if="!State.showOddOnly || item.odd">
|
||||
<div class="p-2 border rounded bg-white shadow-sm">
|
||||
<i class="bi bi-person-fill text-primary"></i>
|
||||
<span $text="item.name"></span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./capability.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,49 +0,0 @@
|
||||
// 1. 生成演示大列表数据 (异步填充)
|
||||
const generateListData = () => {
|
||||
const data = [];
|
||||
data.push({ id: 'r1', label: '研发中心', icon: 'building', parent: '', group: 'group1' });
|
||||
data.push({ id: 'r2', label: '创意中心', icon: 'brush', parent: '', group: 'group2' });
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
data.push({ id: `dev-${i}`, label: `开发 ${i} 组`, parent: 'r1', group: 'group1' });
|
||||
data.push({ id: `design-${i}`, label: `设计 ${i} 组`, parent: 'r2', group: 'group2' });
|
||||
}
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
data.push({ id: `user-${i}`, label: `成员 ${i}`, parent: i <= 50 ? 'dev-1' : 'design-1', icon: 'person' });
|
||||
}
|
||||
State.listData = data;
|
||||
};
|
||||
|
||||
generateListData();
|
||||
|
||||
// 2. 业务逻辑
|
||||
window.resetFormData = () => {
|
||||
Object.assign(State.formData, {
|
||||
username: 'ApigoUser',
|
||||
dateRange: '2026-06-01',
|
||||
endDate: '2026-06-07',
|
||||
theme: '#0d6efd',
|
||||
icon: 'lightning-charge',
|
||||
tags: ['Fast', 'Powerful', 'Easy'],
|
||||
role: 'admin',
|
||||
gender: ['男'],
|
||||
notify: true
|
||||
});
|
||||
};
|
||||
|
||||
window.showComplexDialog = async () => {
|
||||
const result = await UI.showDialog({
|
||||
title: '系统升级确认',
|
||||
message: '<p>检测到有新版本可用,是否立即升级?</p><div class="alert alert-warning">升级期间服务可能会短暂中断。</div>',
|
||||
buttons: ['以后再说', '详细了解', '立即升级'],
|
||||
type: 'warning'
|
||||
});
|
||||
if (result === 3) UI.toast('开始升级...', { type: 'success' });
|
||||
else if (result === 2) UI.alert('当前版本: v1.0.11\n目标版本: v1.1.0\n更新内容: 修复了若干已知 Bug。');
|
||||
};
|
||||
|
||||
window.testHTTP = async () => {
|
||||
UI.toast('正在发起请求...');
|
||||
const resp = await HTTP.get({ url: 'https://jsonplaceholder.typicode.com/todos/1' });
|
||||
if (resp.ok) UI.alert('HTTP 请求成功:\n' + JSON.stringify(resp.result, null, 2));
|
||||
else UI.toast('请求失败: ' + resp.error, { type: 'danger' });
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Capability demo page deep DOM dump', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
|
||||
await page.goto('http://localhost:5173/test/capability.html');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const dump = await page.evaluate(() => {
|
||||
const nav = document.getElementById('mainNav');
|
||||
const form = document.getElementById('demoForm');
|
||||
return {
|
||||
nav: {
|
||||
tagName: nav?.tagName,
|
||||
childCount: nav?.children.length,
|
||||
innerHTML: nav?.innerHTML
|
||||
},
|
||||
form: {
|
||||
tagName: form?.tagName,
|
||||
childCount: form?.children.length,
|
||||
innerHTML: form?.innerHTML
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Deep DOM Dump:', JSON.stringify(dump, null, 2));
|
||||
|
||||
expect(dump.nav.childCount).toBeGreaterThan(0);
|
||||
});
|
||||
@ -1,108 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AutoForm Mega Unit Test</title>
|
||||
<script src="./lib/state.js"></script>
|
||||
<script src="./lib/bootstrap.js"></script>
|
||||
<script src="./lib/base.js"></script>
|
||||
<script>
|
||||
window.fullSchema = [
|
||||
{ name: 't', label: 'Text Input', type: 'text', placeholder: 'Enter name...' },
|
||||
{ name: 's', label: 'Single Select', type: 'select', options: [{label:'Option 1', value:'1'}, {label:'Option 2', value:'2'}] },
|
||||
{ name: 'date', label: 'Single Date', type: 'DatePicker' },
|
||||
{ name: 'range', label: 'Date Range', type: 'DatePicker', setting: { rangeEnd: 'rangeEnd' } },
|
||||
{ name: 'c', label: 'Multi Check', type: 'checkbox', options: ['Apple', 'Banana', 'Orange'] },
|
||||
{ name: 'r', label: 'Single Radio', type: 'radio', options: ['High', 'Medium', 'Low'] },
|
||||
{ name: 'sw', label: 'Enable Linkage', type: 'switch' },
|
||||
{ name: 'linkageField', label: 'Secret Key', type: 'password', if: 'this.data.sw', placeholder: 'Only visible when switch is ON' },
|
||||
{ name: 'cp', label: 'Color Picker', type: 'ColorPicker' },
|
||||
{ name: 'tg', label: 'Tags System', type: 'TagsInput' },
|
||||
{ name: 'txt', label: 'Description', type: 'textarea' }
|
||||
];
|
||||
|
||||
window.searchSchema = [
|
||||
{ name: 'q', type: 'text', placeholder: 'Search...' },
|
||||
{ name: 'cat', type: 'select', placeholder: 'Category', options: ['All', 'Docs', 'Issues'] }
|
||||
];
|
||||
|
||||
window.compactSchema = [
|
||||
{ name: 'user', label: 'User', type: 'text' },
|
||||
{ name: 'role', label: 'Role', type: 'select', options: ['Admin', 'Editor'] }
|
||||
];
|
||||
|
||||
Object.assign(window.State, {
|
||||
formData: { t: 'Apigo', s: '1', c: ['Apple'], sw: false, cp: '#0d6efd', range: '2026-01-01', rangeEnd: '2026-12-31' },
|
||||
searchData: { q: '', cat: '' },
|
||||
compactData: { user: 'Admin', role: 'Admin' }
|
||||
});
|
||||
</script>
|
||||
<style>body { padding: 30px; background: #111; color: #eee; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<h2 class="mb-4">AutoForm Mega Test & Function Showcase</h2>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- 1. 垂直模式 -->
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-dark border-secondary h-100">
|
||||
<div class="card-header border-secondary bg-secondary bg-opacity-10">1. Vertical Mode</div>
|
||||
<div class="card-body">
|
||||
<AutoForm id="formV" vertical $.state.schema="window.fullSchema" $.state.data="State.formData"></AutoForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 响应式水平模式 -->
|
||||
<div class="col-md-8">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header border-secondary bg-secondary bg-opacity-10 d-flex justify-content-between">
|
||||
<span>2. Responsive Horizontal</span>
|
||||
<small class="text-info">Try shrinking the window!</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<AutoForm id="formH" $.state.schema="window.fullSchema" $.state.data="State.formData">
|
||||
<template slot="actions">
|
||||
<button type="button" class="btn btn-outline-info" onclick="UI.toast('Custom Action!')">Custom Button</button>
|
||||
</template>
|
||||
</AutoForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-dark border-secondary mt-4">
|
||||
<div class="card-header border-secondary bg-secondary bg-opacity-10">2b. Forced Horizontal</div>
|
||||
<div class="card-body">
|
||||
<AutoForm id="formFH" horizontal $.state.schema="window.fullSchema" $.state.data="State.formData"></AutoForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<h4 class="text-primary border-bottom pb-2">3. Inline Mode Scenarios</h4>
|
||||
<div class="row align-items-end">
|
||||
<div class="col-auto">
|
||||
<h6>Toolbar (No label, with action)</h6>
|
||||
<div class="p-2 border border-secondary rounded bg-dark d-inline-block shadow-sm">
|
||||
<AutoForm id="formSearch" inline submitlabel="Search" $.state.schema="window.searchSchema" $.state.data="State.searchData"></AutoForm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto ms-4">
|
||||
<h6>Compact Config (With labels, no button)</h6>
|
||||
<div class="p-2 border border-secondary rounded bg-dark d-inline-block shadow-sm">
|
||||
<AutoForm id="formCompact" inline nobutton $.state.schema="window.compactSchema" $.state.data="State.compactData"></AutoForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 card bg-dark border-primary">
|
||||
<div class="card-header border-primary text-primary">Live Data Sync Debug</div>
|
||||
<div class="card-body">
|
||||
<pre class="m-0 text-success" $text="JSON.stringify(State.formData, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -5,25 +5,16 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- 同步加载已构建好的依赖 (通过 test/lib/ 软链接) -->
|
||||
<script src="./lib/state.js"></script>
|
||||
<script src="./lib/bootstrap.js"></script>
|
||||
|
||||
<!-- 同步加载 Base 源码 (用于测试当前正在开发的源码) -->
|
||||
<script src="../src/ui.js"></script>
|
||||
<script src="../src/http.js"></script>
|
||||
<script src="../src/form.js"></script>
|
||||
<script src="../src/list.js"></script>
|
||||
<script src="../src/interaction.js"></script>
|
||||
<script src="../src/controls.js"></script>
|
||||
<script src="../src/nav.js"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "../../state/src/index.js",
|
||||
"@web/base": "../src/index.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="d-flex flex-column vh-100">
|
||||
<script>
|
||||
window.addEventListener('error', e => console.log('GLOBAL ERROR:', e.message, e.filename, e.lineno, e.colno, e.error));
|
||||
window.addEventListener('unhandledrejection', e => console.log('UNHANDLED REJECTION:', e.reason));
|
||||
</script>
|
||||
<div id="results" class="p-2 bg-light border-bottom">Running tests...</div>
|
||||
|
||||
<script>
|
||||
@ -36,76 +27,28 @@
|
||||
group_list.push({ id: i, label: `group ${i}`, summary: `group ${i} summary` })
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.list-container').forEach(el => el.style.display = 'none');
|
||||
const target = document.getElementById('container-' + tabId);
|
||||
if (target) target.style.display = 'flex';
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const btn = document.getElementById('btn-' + tabId);
|
||||
if (btn) btn.classList.add('active');
|
||||
window.activeTab = tabId;
|
||||
}
|
||||
</script>
|
||||
<div class="p-2 border-bottom d-flex gap-2">
|
||||
<button id="btn-ll" class="tab-btn btn btn-sm btn-outline-primary active" onclick="switchTab('ll')">Fast List</button>
|
||||
<button id="btn-gl" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('gl')">Fast Grouped</button>
|
||||
<button id="btn-tt" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('tt')">Fast Tree</button>
|
||||
<button id="btn-ct" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('ct')">Normal Tree</button>
|
||||
<button id="btn-form" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('form')">AutoForm Controls</button>
|
||||
</div>
|
||||
<div class="d-flex flex-fill p-2 overflow-hidden">
|
||||
<div id="container-ll" class="list-container flex-fill d-flex flex-column overflow-hidden">
|
||||
<h5>Fast List (Variable Height)</h5>
|
||||
<FastList fast id="ll" auto-select class="flex-fill d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
|
||||
<div class="d-flex flex-fill flex-wrap overflow-auto">
|
||||
<List fast id="ll" auto-select class="p-4 h-50 w-50 d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
|
||||
<template slot="item">
|
||||
<div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div>
|
||||
</template>
|
||||
</FastList>
|
||||
</div>
|
||||
<div id="container-gl" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
||||
<h5>Fast Grouped List</h5>
|
||||
<FastList fast mode="group" id="gl" auto-select auto-select-group class="flex-fill d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
|
||||
</List>
|
||||
<List fast mode="group" id="gl" auto-select auto-select-group class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
|
||||
$ongroupclick="console.log(index, item)">
|
||||
</FastList>
|
||||
</div>
|
||||
<div id="container-tt" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
||||
<h5>Fast Tree List</h5>
|
||||
<FastList fast mode="tree" id="tt" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></FastList>
|
||||
</div>
|
||||
<div id="container-ct" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
||||
<h5>Normal Tree List (Collapsible)</h5>
|
||||
<FastList mode="tree" collapsible id="ct" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
|
||||
</FastList>
|
||||
</div>
|
||||
<div id="container-form" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
|
||||
<h5>AutoForm Controls</h5>
|
||||
<AutoForm id="test-form" class="border p-3 rounded bg-light" $onsubmit="UI.toast('Form Submitted: ' + JSON.stringify(event.detail))"></AutoForm>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById('test-form');
|
||||
if (form) {
|
||||
form.state.schema = [
|
||||
{ name: 'startDate', label: 'Date Range', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
|
||||
{ name: 'endDate', label: 'End Date (Shadow)', type: 'date' },
|
||||
{ name: 'themeColor', label: 'Theme Color', type: 'ColorPicker' },
|
||||
{ name: 'appIcon', label: 'App Icon', type: 'IconPicker' }
|
||||
];
|
||||
form.data = { startDate: '2026-05-01', endDate: '2026-05-31', themeColor: '#ff0000', appIcon: 'gear' };
|
||||
}
|
||||
}, 600);
|
||||
</script>
|
||||
</div>
|
||||
</List>
|
||||
<List fast mode="tree" id="tt" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
|
||||
<List mode="tree" collapsible id="ct" auto-select class="p-4 h-50 w-50 d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
|
||||
</List>
|
||||
</div>
|
||||
|
||||
<!-- 加载测试脚本 -->
|
||||
<script src="./base.test.js"></script>
|
||||
<script>
|
||||
<script type="module">
|
||||
import { runTests } from './base.test.js';
|
||||
|
||||
async function runAll() {
|
||||
const results = document.getElementById('results');
|
||||
try {
|
||||
// Wait for components to initialize (async due to MutationObserver)
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
// Wait for components to initialize
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await runTests();
|
||||
results.innerHTML = '<h1 style="color: green; font-size: 1.2rem">All Tests Passed 🎉</h1>';
|
||||
window.testStatus = 'passed';
|
||||
|
||||
917
test/lib/base.js
917
test/lib/base.js
File diff suppressed because one or more lines are too long
1
test/lib/bootstrap.js
vendored
1
test/lib/bootstrap.js
vendored
@ -1 +0,0 @@
|
||||
../../../bootstrap/dist/bootstrap.js
|
||||
@ -1 +0,0 @@
|
||||
../../../state/dist/state.js
|
||||
@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>List Mega Test (Virtual Scroll Stress Test)</title>
|
||||
<script src="./lib/state.js"></script>
|
||||
<script src="./lib/bootstrap.js"></script>
|
||||
<script src="./lib/base.js"></script>
|
||||
<style>
|
||||
body { height: 100vh; background: #111; color: #eee; }
|
||||
.test-container { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); gap: 20px; height: 100%; padding: 20px; box-sizing: border-box; }
|
||||
.list-card { display: flex; flex-direction: column; overflow: hidden; border: 1px solid #444; border-radius: 8px; background: #222; }
|
||||
.list-card h5 { padding: 10px; margin: 0; background: #333; border-bottom: 1px solid #444; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<div class="list-card">
|
||||
<h5>1. Standard List (Normal)</h5>
|
||||
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" auto-select></List>
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<h5>2. Group List (Mode: Group)</h5>
|
||||
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups"></List>
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<h5>3. Tree List (Mode: Tree + Collapsible)</h5>
|
||||
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems"></List>
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>
|
||||
<List id="listFast" fast class="flex-fill" $.state.list="State.bigItems" auto-select>
|
||||
<!-- 自定义插槽:必须保留 list-group-item 类和 $onupdate 钩子 -->
|
||||
<template slot="item">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)"
|
||||
$class="list-group-item list-group-item-action d-flex flex-column w-100 py-2 \${this.state?.selectedItem===item.id?'active':''}"
|
||||
$onclick="this.selectItem(item,index)">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-person-circle me-2 text-primary"></i>
|
||||
<span class="fw-bold" $text="item.label"></span>
|
||||
<span class="ms-auto badge bg-dark text-muted" $text="'#' + item.id"></span>
|
||||
</div>
|
||||
<div class="small text-secondary mt-1" style="white-space: normal; line-height: 1.4" $text="item.summary"></div>
|
||||
</div>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 1. 准备标准数据
|
||||
const stdItems = [];
|
||||
for(let i=1; i<=50; i++) stdItems.push({ id: i, label: 'Item ' + i, summary: 'This is standard item ' + i });
|
||||
|
||||
const groups = [
|
||||
{ id: 'g1', label: 'Engineering', summary: 'Technical staff' },
|
||||
{ id: 'g2', label: 'Marketing', summary: 'Growth team' },
|
||||
{ id: 'g3', label: 'Sales', summary: 'Revenue drivers' }
|
||||
];
|
||||
const grpItems = [];
|
||||
for(let i=1; i<=100; i++) {
|
||||
grpItems.push({ id: i, label: 'Member ' + i, group: i % 3 === 0 ? 'g1' : (i % 3 === 1 ? 'g2' : 'g3') });
|
||||
}
|
||||
|
||||
const treeItems = [
|
||||
{ id: 'root1', label: 'Company HQ', parent: '' },
|
||||
{ id: 'dept1', label: 'R&D Division', parent: 'root1' },
|
||||
{ id: 'team1', label: 'Framework Team', parent: 'dept1' },
|
||||
{ id: 'team2', label: 'UI Team', parent: 'dept1' },
|
||||
{ id: 'dept2', label: 'Operations', parent: 'root1' },
|
||||
{ id: 'team3', label: 'Cloud Ops', parent: 'dept2' },
|
||||
{ id: 'root2', label: 'Overseas Branch', parent: '' },
|
||||
{ id: 'dept3', label: 'Asia Pacific', parent: 'root2' }
|
||||
];
|
||||
|
||||
// 2. 准备 10,000 条极端高度差异数据
|
||||
const bigItems = [];
|
||||
const lorem = [
|
||||
"Short summary.",
|
||||
"Medium length summary that might take two lines in a narrow container to test dynamic height measurement accurately. We are checking if the VirtualScroll correctly captures this.",
|
||||
"EXTREME HEIGHT TEST: " + new Array(15).fill("This is a very long line of text intended to force the container to expand significantly. ").join(" ") + " This block should be at least 200px-300px high depending on the width.",
|
||||
"Normal sentence for baseline."
|
||||
];
|
||||
|
||||
for(let i=1; i<=10000; i++) {
|
||||
bigItems.push({
|
||||
id: i,
|
||||
label: 'Virtual Item ' + i,
|
||||
summary: lorem[i % 4]
|
||||
});
|
||||
}
|
||||
|
||||
State.stdItems = stdItems;
|
||||
State.groups = groups;
|
||||
State.grpItems = grpItems;
|
||||
State.treeItems = treeItems;
|
||||
State.bigItems = bigItems;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,59 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Empirical Mega Verification', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
|
||||
// 1. Verify AutoForm
|
||||
console.log('--- Verifying AutoForm Mega ---');
|
||||
await page.goto('http://localhost:5173/test/form_test.html');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const checkForm = async (id) => {
|
||||
return await page.evaluate((fid) => {
|
||||
const form = document.getElementById(fid);
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
const labels = form.querySelectorAll('label');
|
||||
return {
|
||||
id: fid,
|
||||
inputCount: inputs.length,
|
||||
labelCount: labels.length,
|
||||
html: form.innerHTML.substring(0, 100)
|
||||
};
|
||||
}, id);
|
||||
};
|
||||
|
||||
const vResult = await checkForm('formV');
|
||||
const hResult = await checkForm('formH');
|
||||
const iResult = await checkForm('formI');
|
||||
|
||||
console.log('Form results:', { vResult, hResult, iResult });
|
||||
expect(vResult.inputCount).toBeGreaterThan(5);
|
||||
expect(hResult.inputCount).toBeGreaterThan(5);
|
||||
|
||||
// 2. Verify List
|
||||
console.log('--- Verifying List Mega ---');
|
||||
await page.goto('http://localhost:5173/test/list_test.html');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const checkList = async (id) => {
|
||||
return await page.evaluate((lid) => {
|
||||
const list = document.getElementById(lid);
|
||||
const items = list.querySelectorAll('.list-group-item');
|
||||
return {
|
||||
id: lid,
|
||||
itemCount: items.length,
|
||||
labels: Array.from(items).map(i => i.textContent.trim())
|
||||
};
|
||||
}, id);
|
||||
};
|
||||
|
||||
const stdResult = await checkList('listStd');
|
||||
const grpResult = await checkList('listGrp');
|
||||
const treeResult = await checkList('listTree');
|
||||
|
||||
console.log('List results:', { stdResult, grpResult, treeResult });
|
||||
|
||||
expect(stdResult.itemCount).toBeGreaterThan(0);
|
||||
expect(grpResult.itemCount).toBeGreaterThan(2);
|
||||
expect(treeResult.itemCount).toBeGreaterThan(0);
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Stress test: Virtual List should handle 10,000 items with dynamic height', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
|
||||
await page.goto('http://localhost:8082/test/list_test.html');
|
||||
|
||||
const listFast = page.locator('#listFast');
|
||||
await expect(listFast).toBeVisible();
|
||||
await page.waitForFunction(() => document.querySelectorAll('#listFast .list-group-item').length > 0);
|
||||
|
||||
// Check initial state
|
||||
const scrollTop = await listFast.evaluate(e => e.scrollTop);
|
||||
const scrollHeight = await listFast.evaluate(e => e.scrollHeight);
|
||||
const clientHeight = await listFast.evaluate(e => e.clientHeight);
|
||||
console.log(`Initial: scrollTop=${scrollTop}, scrollHeight=${scrollHeight}, clientHeight=${clientHeight}`);
|
||||
|
||||
// Attach event listener to see if scroll fires
|
||||
await listFast.evaluate(e => {
|
||||
e.addEventListener('scroll', () => console.log('SCROLL EVENT FIRED! new scrollTop:', e.scrollTop));
|
||||
});
|
||||
|
||||
// Scroll to the very end
|
||||
await listFast.evaluate(e => e.scrollTop = e.scrollHeight);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const finalScrollTop = await listFast.evaluate(e => e.scrollTop);
|
||||
console.log(`Final: scrollTop=${finalScrollTop}`);
|
||||
|
||||
const lastItemText = await listFast.locator('.list-group-item').last().textContent();
|
||||
console.log('Last rendered item text:', lastItemText);
|
||||
expect(lastItemText).toContain('Virtual Item 10000');
|
||||
});
|
||||
@ -1,20 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Check styles and globals', async ({ page }) => {
|
||||
await page.goto('http://localhost:8082/test/form_test.html');
|
||||
|
||||
// Check if auto-grid-form is grid (horizontal mode)
|
||||
const formH = page.locator('#formH form');
|
||||
const display = await formH.evaluate(el => window.getComputedStyle(el).display);
|
||||
console.log('formH display:', display);
|
||||
expect(display).toBe('grid');
|
||||
|
||||
// Get color picker input to check height
|
||||
const colorInput = page.locator('#formH input[type="color"]');
|
||||
if (await colorInput.count() > 0) {
|
||||
const height = await colorInput.evaluate(el => window.getComputedStyle(el).height);
|
||||
console.log('colorInput height:', height);
|
||||
// It shouldn't be very small (like 0 or 2px)
|
||||
expect(parseInt(height)).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
@ -5,9 +5,8 @@ import terser from '@rollup/plugin-terser';
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@apigo.cc/state': resolve(__dirname, '../state/src/index.js'),
|
||||
'@apigo.cc/bootstrap': resolve(__dirname, '../bootstrap/src/index.js'),
|
||||
'@apigo.cc/base': resolve(__dirname, 'src/index.js')
|
||||
'@web/state': resolve(__dirname, '../state/src/index.js'),
|
||||
'@web/base': resolve(__dirname, 'src/index.js')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
@ -18,29 +17,20 @@ export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.js'),
|
||||
name: 'ApigoBase',
|
||||
formats: ['iife']
|
||||
name: 'Base',
|
||||
formats: ['es']
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['@apigo.cc/state', '@apigo.cc/bootstrap'],
|
||||
external: ['@web/state'],
|
||||
output: [
|
||||
{
|
||||
format: 'iife',
|
||||
name: 'ApigoBase',
|
||||
format: 'es',
|
||||
entryFileNames: 'base.js',
|
||||
globals: {
|
||||
'@apigo.cc/state': 'ApigoState',
|
||||
'@apigo.cc/bootstrap': 'bootstrap'
|
||||
}
|
||||
minifyInternalExports: false
|
||||
},
|
||||
{
|
||||
format: 'iife',
|
||||
name: 'ApigoBase',
|
||||
format: 'es',
|
||||
entryFileNames: 'base.min.js',
|
||||
globals: {
|
||||
'@apigo.cc/state': 'ApigoState',
|
||||
'@apigo.cc/bootstrap': 'bootstrap'
|
||||
},
|
||||
plugins: [terser()]
|
||||
}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user