diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..002108b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## [v1.0.1] - 2026-07-13 + +- **新增**: + - DataGrid 支持数据 API 自动加载、安全 filter/sort 查询、服务端保存和高级系统字段模式。 + - AutoForm 支持数据库字段描述、只读 label 与 divider 类型。 + - API 根据请求 data 自动选择 POST,Nav 明确动作事件语义。 +- **修复**: + - Select 初始值、异步 DataGrid 数据绑定与 Modal 长内容滚动。 diff --git a/README.md b/README.md index 6ddf133..8a57eca 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Pages load `ui.js` directly from a CDN and explicitly declare required public APIs: ```html - + ``` `ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute accepts public component names and the `base` and `form` categories; both forms can be mixed. Load `ui.js` in `
` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render. -`npm run build` does not bundle source files or create a `dist` directory. It updates the release version, writes source-file cache versions into `ui.js`, and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files. +`npm run build` does not bundle source files or create a `dist` directory. It first copies `../state/dist/state.js` and `../bootstrap/dist/bootstrap.js` into `frameworks/`, then writes their source-file modification-time cache versions into `ui.js` and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files. The UI package version remains independent of its framework packages. For AI-oriented API documentation, start with [AI.yaml](AI.yaml). diff --git a/README.zh-CN.md b/README.zh-CN.md index a21631c..8a534c4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ `@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件: ```html - + ``` `ui.js` 默认加载 `frameworks/` 和底层 `utilities/`。`components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。 @@ -14,4 +14,4 @@ AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。 -`npm run build` 不会打包源码,也不会生成 `dist`。它会更新发布版本、把各源码文件的修改时间版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`。 +`npm run build` 不会打包源码,也不会生成 `dist`。它会先将 `../state/dist/state.js` 和 `../bootstrap/dist/bootstrap.js` 复制到 `frameworks/`,再把所有源码文件的最后修改时间缓存版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`。UI 包的版本独立于前置框架包。 diff --git a/components/base/API.js b/components/base/API.js index cbd718c..a34e8c0 100644 --- a/components/base/API.js +++ b/components/base/API.js @@ -8,7 +8,7 @@ const APIComponent = globalThis.Component.register('API', container => { container.request = globalThis.NewState({ url: '', - method: 'GET', + method: '', headers: {}, data: null, timeout: 10000, @@ -32,6 +32,7 @@ const APIComponent = globalThis.Component.register('API', container => { return new Promise((resolve, reject) => { const req = { ...container.request, ...opt } if (!req.url) throw new Error('.url is required') + if (!req.method) req.method = req.data == null ? 'GET' : 'POST' req.headers = { ...container.request.headers, ...opt.headers } container.response.loading = true globalThis.HTTP.request(req).then(resp => { diff --git a/components/base/API.min.js b/components/base/API.min.js index 936ad3c..10e18e7 100644 --- a/components/base/API.min.js +++ b/components/base/API.min.js @@ -1 +1 @@ -const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"GET",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})}); +const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.method||(o.method=null==o.data?"GET":"POST"),o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})}); diff --git a/components/base/API.test.html b/components/base/API.test.html index 92fad26..06355e3 100644 --- a/components/base/API.test.html +++ b/components/base/API.test.html @@ -19,27 +19,28 @@ const tick = () => new Promise(resolve => setTimeout(resolve, 80)) const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } } window.runTest = async () => { - const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui'], percent: 0 } } + const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui'], percent: 0 } } await tick() let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ }) autoApi.request.url = '/__http__/json?next=1'; await tick() assert(testResult, 'auto', autoApi.response.ok === true && autoApi.result.method === 'GET' && autoResponses === 1, 'auto requests again after request changes') - assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === 'GET' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults') + assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === '' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults') assert(testResult, 'response', autoApi.response.loading === false && autoApi.response.status === 200 && autoApi.response.responseType === 'json' && typeof autoApi.response.headers === 'object', 'response exposes loading, status, headers, and responseType') assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings') let responseEvent manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true }) const response = await manualApi.do(successRequest) assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result') + const postResponse = await manualApi.do({ url: '/__http__/json', data: { action: 'tables' } }); assert(testResult, 'post-default', postResponse.ok && manualApi.result.method === 'POST', 'data defaults an unspecified method to POST') assert(testResult, 'response-event', responseEvent?.status === 200, 'response event exposes the complete HTTP response') - assert(testResult, 'result', manualApi.result.method === 'GET', 'result exposes the latest response result') + assert(testResult, 'result', manualApi.result.method === 'POST', 'result exposes the latest response result') let failed = false; let errorEvent manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true }) try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = true } assert(testResult, 'error', failed && manualApi.response.status === 200 && manualApi.response.ok === true && manualApi.result.error === 'application failed', 'application error rejects and preserves response state') assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail') assert(testResult, 'noui', !document.querySelector('Toast'), 'noui suppresses automatic error Toast') - testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult + testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult } document.querySelector('#runAll').onclick = window.runTest diff --git a/components/base/API.yaml b/components/base/API.yaml index e529e13..2afd389 100644 --- a/components/base/API.yaml +++ b/components/base/API.yaml @@ -9,7 +9,7 @@ properties: request: default: url: '' - method: GET + method: "GET when data is null; POST when data is present, unless explicitly set." headers: {} data: null timeout: 10000 @@ -34,6 +34,7 @@ events: rules: - request.url is required before calling do. + - Without request.method, API uses POST when request.data is present and GET otherwise. - Bind request with $.request when external state owns request configuration. examples: @@ -47,6 +48,9 @@ examples: tests: diff --git a/components/base/AutoForm.js b/components/base/AutoForm.js index 2651d87..5b69739 100644 --- a/components/base/AutoForm.js +++ b/components/base/AutoForm.js @@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`