2026-06-06 11:45:53 +08:00
|
|
|
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
|
2026-05-14 20:04:31 +08:00
|
|
|
import { HTTP } from './http.js'
|
|
|
|
|
|
2026-06-08 21:57:18 +08:00
|
|
|
/**
|
|
|
|
|
* AutoForm 蓝图定义
|
|
|
|
|
*/
|
|
|
|
|
const AUTOFORM_BLUEPRINT = Util.makeDom(/*html*/`
|
|
|
|
|
<div class="auto-form-root">
|
|
|
|
|
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
|
|
|
|
|
|
|
|
|
<template $each="this.state.schema || []">
|
|
|
|
|
<div style="display:contents" $if="this.checkIf(item)">
|
|
|
|
|
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
|
|
|
|
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
|
|
|
|
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control \${item.type === 'number' ? 'text-end' : ''}">
|
|
|
|
|
|
|
|
|
|
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select">
|
|
|
|
|
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
|
|
|
|
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
|
|
|
|
|
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
|
|
|
|
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
|
|
|
|
|
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
|
|
|
|
|
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" 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>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div $if="!this.nobutton" $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
|
|
|
|
|
<div slot-id="actions"></div>
|
|
|
|
|
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
`)
|
|
|
|
|
|
|
|
|
|
const AUTOFORM_STYLE = Util.makeDom(/*html*/`<style>
|
|
|
|
|
.auto-grid-form { display: block; }
|
|
|
|
|
.auto-grid-form .col-form-label { text-align: left; margin-bottom: 0.25rem; padding-top: 0; }
|
|
|
|
|
|
|
|
|
|
@media (min-width: 576px) {
|
|
|
|
|
.auto-grid-form { display: grid; grid-template-columns: max-content 1fr; gap: 0 1.5rem; }
|
|
|
|
|
.auto-grid-form .col-form-label { text-align: right; margin-bottom: 1rem; padding-right: 0; max-width: 200px; padding-top: calc(0.375rem + 1px); }
|
|
|
|
|
.auto-grid-form [control-wrapper] { min-height: calc(2.25rem + 2px); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.auto-grid-form.forced-horizontal { display: grid !important; grid-template-columns: max-content 1fr !important; gap: 0 1.5rem !important; }
|
|
|
|
|
.auto-grid-form.forced-horizontal .col-form-label { text-align: right !important; margin-bottom: 1rem !important; padding-right: 0 !important; max-width: 200px !important; padding-top: calc(0.375rem + 1px) !important; }
|
|
|
|
|
.auto-grid-form.forced-horizontal [control-wrapper] { min-height: calc(2.25rem + 2px) !important; }
|
|
|
|
|
|
|
|
|
|
.auto-form-root .form-check-input { width: 1.2em; height: 1.2em; }
|
|
|
|
|
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
|
|
|
|
</style>`)
|
|
|
|
|
|
2026-05-14 20:04:31 +08:00
|
|
|
Component.register('AutoForm', container => {
|
|
|
|
|
if (!container.state.schema) container.state.schema = []
|
2026-06-06 11:45:53 +08:00
|
|
|
|
|
|
|
|
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 || {});
|
|
|
|
|
|
2026-05-14 20:04:31 +08:00
|
|
|
container.vertical = container.hasAttribute('vertical')
|
2026-06-08 21:57:18 +08:00
|
|
|
container.horizontal = container.hasAttribute('horizontal')
|
2026-05-17 16:59:44 +08:00
|
|
|
container.inline = container.hasAttribute('inline')
|
2026-06-08 21:57:18 +08:00
|
|
|
container.nobutton = container.hasAttribute('nobutton')
|
2026-05-14 20:04:31 +08:00
|
|
|
container.request = { method: 'POST' }
|
|
|
|
|
container.response = {}
|
|
|
|
|
container.result = null
|
2026-05-17 17:03:18 +08:00
|
|
|
|
2026-05-14 20:04:31 +08:00
|
|
|
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 }))
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-06-08 21:57:18 +08:00
|
|
|
|
|
|
|
|
container.checkIf = (item) => {
|
|
|
|
|
if (!item.if) return true
|
|
|
|
|
try {
|
|
|
|
|
const fn = new Function('Hash', 'LocalStorage', 'State', 'item', 'data', 'return ' + item.if)
|
|
|
|
|
return fn.call(container, globalThis.Hash, globalThis.LocalStorage, globalThis.State, item, container.data)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
|
|
|
|
|
|
|
|
|
|
const findAnchorInBlueprint = (root) => {
|
|
|
|
|
let f = root.querySelector('[control-wrapper]');
|
|
|
|
|
if (f) return f;
|
|
|
|
|
for (const t of root.querySelectorAll('template')) {
|
|
|
|
|
f = findAnchorInBlueprint(t.content); if (f) return f;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-14 20:04:31 +08:00
|
|
|
|
|
|
|
|
export const AutoForm = {
|
2026-06-06 22:30:12 +08:00
|
|
|
customTypes: [],
|
2026-06-04 18:52:30 +08:00
|
|
|
register: (name, typeName) => {
|
2026-06-06 11:45:53 +08:00
|
|
|
const type = typeName || name
|
|
|
|
|
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
|
|
|
|
AutoForm.customTypes.push({ name, typeName: type })
|
2026-06-08 21:57:18 +08:00
|
|
|
AutoForm._addAutoFormComponent(name, type)
|
2026-05-14 20:04:31 +08:00
|
|
|
}
|
2026-06-08 21:57:18 +08:00
|
|
|
},
|
|
|
|
|
_addAutoFormComponent: (name, type) => {
|
|
|
|
|
const wrapper = findAnchorInBlueprint(AUTOFORM_BLUEPRINT)
|
|
|
|
|
if (wrapper) {
|
|
|
|
|
const node = Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${type.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="w-100"></${name}>`)
|
|
|
|
|
wrapper.appendChild(node)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 20:04:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-08 21:57:18 +08:00
|
|
|
/**
|
|
|
|
|
* TagsInput
|
|
|
|
|
* 100% 还原 base_original.js 逻辑与写法
|
|
|
|
|
*/
|
2026-05-14 20:04:31 +08:00
|
|
|
Component.register('TagsInput', container => {
|
2026-06-08 21:57:18 +08:00
|
|
|
container._thisObj = container; // 夺回上下文主权
|
2026-05-14 20:04:31 +08:00
|
|
|
container.addEventListener('bind', e => {
|
2026-05-23 17:37:24 +08:00
|
|
|
container.state.tags = Array.isArray(e.detail) ? e.detail : []
|
|
|
|
|
})
|
2026-05-14 20:04:31 +08:00
|
|
|
}, Util.makeDom(/*html*/`
|
2026-06-08 21:57:18 +08:00
|
|
|
<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) {
|
|
|
|
|
if (['Backspace', 'Delete'].includes(event.key)) {
|
|
|
|
|
event.preventDefault()
|
2026-05-23 17:37:24 +08:00
|
|
|
this.state.tags.splice(index, 1)
|
|
|
|
|
this.state.tags = this.state.tags
|
2026-06-08 21:57:18 +08:00
|
|
|
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
2026-05-23 17:37:24 +08:00
|
|
|
Promise.resolve().then(() => {
|
2026-06-08 21:57:18 +08:00
|
|
|
const buttons = $$(this, 'button');
|
|
|
|
|
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
2026-05-23 17:37:24 +08:00
|
|
|
})
|
|
|
|
|
}
|
2026-06-08 21:57:18 +08:00
|
|
|
})}' $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) {
|
|
|
|
|
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 = ''
|
2026-05-14 20:04:31 +08:00
|
|
|
}
|
2026-06-08 21:57:18 +08:00
|
|
|
})}'>
|
2026-05-14 20:04:31 +08:00
|
|
|
</div>
|
2026-06-08 21:57:18 +08:00
|
|
|
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
2026-05-14 20:04:31 +08:00
|
|
|
|
|
|
|
|
AutoForm.register('TagsInput')
|