// 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+<").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*/` `)) Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/` `)) 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*/`
`), Util.makeDom(/*html*/`
`)) 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*/`
`), Util.makeDom(/*html*/``)) 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]">`)) } } 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*/`
`), Util.makeDom(/*html*/``)) 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*/`
`)) 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*/`
\${item[this.labelfield]}
`)) 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*/`
`)) Component.register('FastGroupedList', container => { Component.getSetupFunction('List')(container) Component.getSetupFunction('GroupedList')(container) }, Util.makeDom(/*html*/`
\${item[this.grouplabelfield]}
\${item[this.labelfield]}
`)) 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*/`
`)) Component.register('FastTree', container => { Component.getSetupFunction('List')(container) Component.getSetupFunction('Tree')(container) }, Util.makeDom(/*html*/`
`)) 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*/`
`)) 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*/` `)) 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*/`
`)) })()