Compare commits

...

2 Commits

Author SHA1 Message Date
AI Engineer
93f6953f4c chore(base): 同步版本号至1.0.21(by AI) 2026-06-28 00:01:22 +08:00
AI Engineer
0b8e1b2952 fix(base): 修复导航与API预设请求(by AI) 2026-06-28 00:00:16 +08:00
8 changed files with 270 additions and 88 deletions

View File

@ -1,5 +1,10 @@
# Changelog
## [1.0.21] - 2026-06-27
### Fixed
- **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state.
- **Nav**: Restored horizontal dropdown behavior by removing container-level clipping, added `label`/`class` support for menu grouping, and switched vertical mode dropdown groups to inline expansion so long sidebars can scroll without popup clipping.
## [1.0.18] - 2026-06-11
### Changed
- **Component-Based Refactor**: Reorganized source code into independent, PascalCase named modules (e.g., `AutoForm.js`, `TagsInput.js`, `List.js`) to improve maintainability.

View File

@ -24,9 +24,9 @@
<!-- 1. 基础状态机 (地基) -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.18/dist/state.min.js"></script>
<!-- 2. Bootstrap 适配层 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.7/dist/bootstrap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script>
<!-- 3. 本业务组件库 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.18/dist/base.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.21/dist/base.min.js"></script>
```
<script>
// 4. 数据先行 (在 body 解析前定义)
@ -215,6 +215,7 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
// 1. 预先确定品牌与菜单数据
const myBrand = { icon: 'shield-lock', label: '安全控制台' };
const myNavList = [
{ type: 'label', label: '主导航', class: 'mt-1' },
{ type: 'button', name: 'dashboard', label: '仪表盘', icon: 'speedometer' },
{
type: 'dropdown',
@ -222,7 +223,8 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
label: '系统设置',
icon: 'gear',
list: [
{ type: 'button', name: 'profile', label: '个人信息', icon: 'person' },
{ type: 'label', label: '偏好设置' },
{ type: 'button', name: 'profile', label: '个人信息', icon: 'person', class: 'ps-2' },
{ type: 'switch', name: 'darkMode', label: '暗黑模式', icon: 'moon', bind: LocalStorage, name: 'darkMode' }
]
},
@ -241,6 +243,9 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
```
* **AI 核心要点**
* 常规导航项被点击时(且 `noselect` 不为 true会自动更新全局 `Hash.nav = item.name`
* `label` 可在一级或二级菜单中作为分组标题使用,`class` 可附加 Bootstrap 工具类(如 `ps-2`)做缩进或样式微调。
* `dropdown` 子项默认只派发 `nav` 事件,不自动写入 `Hash.nav`,适合承载偏好设置、开关等功能菜单。
* 垂直模式下二级菜单以内联展开方式显示,外层布局需提供局部滚动边界。
---

107
dist/base.js vendored
View File

@ -399,9 +399,31 @@
}
globalThis.MouseMover = MouseMover;
const APIComponent = globalThis.Component.register("API", (container) => {
container.request = globalThis.NewState({ url: "", method: "GET", headers: {}, data: null, timeout: 1e4, responseType: "" });
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: "", result: null });
container.result = globalThis.NewState();
const presetRequest = container.request && typeof container.request === "object" ? container.request : {};
const presetResponse = container.response && typeof container.response === "object" ? container.response : {};
const presetResult = container.result && typeof container.result === "object" ? container.result : {};
container.request = globalThis.NewState({
url: "",
method: "GET",
headers: {},
data: null,
timeout: 1e4,
responseType: "",
...presetRequest,
headers: { ...presetRequest.headers || {} }
});
container.response = globalThis.NewState({
loading: false,
ok: null,
status: null,
error: null,
headers: {},
responseType: "",
result: null,
...presetResponse,
headers: { ...presetResponse.headers || {} }
});
container.result = globalThis.NewState(presetResult);
container.do = (opt = {}) => {
return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt };
@ -860,56 +882,105 @@
));
globalThis.Component.register("Nav", (container) => {
container.vertical = container.hasAttribute("vertical");
container.state.openName = container.state.openName || null;
container.click = (item, noselect) => {
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
};
container.clickSubitem = (item) => {
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
};
container.toggleGroup = (item) => {
container.state.openName = container.state.openName === item.name ? null : item.name;
container.dispatchEvent(new CustomEvent("nav", { detail: { item, open: container.state.openName === item.name }, bubbles: false }));
};
}, globalThis.Util.makeDom(
/*html*/
`
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
<div class="d-flex align-items-center gap-2">
<template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template>
<template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
</template>
<template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
</template>
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
</div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
<template $each="this.state?.list || []">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
<template $if="item.type==='label'">
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
</template>
<template $if="item.type==='button'">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'w-100 text-start py-1 px-2' : ''} \${item.class || ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
</button>
</template>
<template $if="item.type==='dropdown'">
<div class="dropdown">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
<template $if="this.vertical">
<div class="w-100">
<button type="button" $class="nav-link w-100 text-start py-1 px-2 d-flex align-items-center justify-content-between \${item.class || ''}" $onclick="this.toggleGroup(item)">
<span class="d-inline-flex align-items-center">
<i $class="bi bi-\${item.icon} me-2"></i><span class="text-truncate" $text="item.label"></span>
</span>
<i $class="bi \${this.state.openName===item.name?'bi-chevron-down':'bi-chevron-right'} small ms-2"></i>
</button>
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
<template $if="this.state.openName===item.name">
<div class="d-flex flex-column w-100 ps-2 mt-1">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'">
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
</template>
<template $if="subitem.type==='switch'">
<div class="d-flex align-items-center">
<div $class="d-flex align-items-center px-2 py-2 \${subitem.class || ''}">
<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 class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</template>
</div>
</template>
</div>
</template>
<template $if="!this.vertical">
<div class="dropdown">
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" 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-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'">
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
</template>
<template $if="subitem.type==='switch'">
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
<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 ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</template>
</div>
</div>
</template>
</template>
</div>
</template>
</div>
</div>
`
));
globalThis.Component.register("Resizer", (container) => {

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "@apigo.cc/base",
"version": "1.0.20",
"version": "1.0.21",
"type": "module",
"main": "dist/base.js",
"files": [

View File

@ -2,9 +2,32 @@
* API Component Module
*/
const APIComponent = globalThis.Component.register('API', container => {
container.request = globalThis.NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
container.result = globalThis.NewState()
const presetRequest = container.request && typeof container.request === 'object' ? container.request : {}
const presetResponse = container.response && typeof container.response === 'object' ? container.response : {}
const presetResult = container.result && typeof container.result === 'object' ? container.result : {}
container.request = globalThis.NewState({
url: '',
method: 'GET',
headers: {},
data: null,
timeout: 10000,
responseType: '',
...presetRequest,
headers: { ...(presetRequest.headers || {}) }
})
container.response = globalThis.NewState({
loading: false,
ok: null,
status: null,
error: null,
headers: {},
responseType: '',
result: null,
...presetResponse,
headers: { ...(presetResponse.headers || {}) }
})
container.result = globalThis.NewState(presetResult)
container.do = (opt = {}) => {
return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt }

View File

@ -3,52 +3,101 @@
*/
globalThis.Component.register('Nav', container => {
container.vertical = container.hasAttribute('vertical')
container.state.openName = container.state.openName || null
container.click = (item, noselect) => {
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
}
container.clickSubitem = item => {
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
}
container.toggleGroup = item => {
container.state.openName = container.state.openName === item.name ? null : item.name
container.dispatchEvent(new CustomEvent('nav', { detail: { item, open: container.state.openName === item.name }, bubbles: false }))
}
}, globalThis.Util.makeDom(/*html*/`
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
<div class="d-flex align-items-center gap-2">
<template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template>
<template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
</template>
<template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
</template>
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
</div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
<template $each="this.state?.list || []">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
<template $if="item.type==='label'">
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
</template>
<template $if="item.type==='button'">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'w-100 text-start py-1 px-2' : ''} \${item.class || ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
</button>
</template>
<template $if="item.type==='dropdown'">
<div class="dropdown">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
<template $if="this.vertical">
<div class="w-100">
<button type="button" $class="nav-link w-100 text-start py-1 px-2 d-flex align-items-center justify-content-between \${item.class || ''}" $onclick="this.toggleGroup(item)">
<span class="d-inline-flex align-items-center">
<i $class="bi bi-\${item.icon} me-2"></i><span class="text-truncate" $text="item.label"></span>
</span>
<i $class="bi \${this.state.openName===item.name?'bi-chevron-down':'bi-chevron-right'} small ms-2"></i>
</button>
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
<template $if="this.state.openName===item.name">
<div class="d-flex flex-column w-100 ps-2 mt-1">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'">
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
</template>
<template $if="subitem.type==='switch'">
<div class="d-flex align-items-center">
<div $class="d-flex align-items-center px-2 py-2 \${subitem.class || ''}">
<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 class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</template>
</div>
</template>
</div>
</template>
<template $if="!this.vertical">
<div class="dropdown">
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" 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-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'">
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
</template>
<template $if="subitem.type==='switch'">
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
<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 ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</template>
</div>
</div>
</template>
</template>
</div>
</template>
</div>
</div>
`))

View File

@ -28,6 +28,35 @@ export async function runTests() {
if (!apiResp.ok) throw new Error('API component request failed');
api.remove();
console.log('Testing API declarative preset request fields...');
LocalStorage.secret = 'api-secret-token';
const presetApi = document.createElement('API');
presetApi.setAttribute('$.request.url', "'/echo'");
presetApi.setAttribute('$.request.method', "'POST'");
presetApi.setAttribute('$.request.data.secret', "LocalStorage.secret || ''");
document.body.appendChild(presetApi);
await new Promise(r => setTimeout(r, 50));
const originalFetch = globalThis.fetch;
let capturedBody = null;
globalThis.fetch = async (_url, options = {}) => {
capturedBody = options.body;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
try {
const presetResp = await presetApi.do();
if (!presetResp.ok) throw new Error('Declarative API preset request failed');
} finally {
globalThis.fetch = originalFetch;
presetApi.remove();
}
if (capturedBody !== JSON.stringify({ secret: 'api-secret-token' })) {
throw new Error('API declarative preset request.data.secret was not preserved');
}
// 5. AutoForm & TagsInput Test
console.log('Testing AutoForm...');
const { NewState } = await import('@apigo.cc/state');