feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
export const stateNames = {
|
||||
queued: '排队中', running: '运行中', succeeded: '进程成功', failed: '失败',
|
||||
cancelled: '已取消', timed_out: '已超时', interrupted: '已中断',
|
||||
ready: '可运行', needs_configuration: '待配置', missing: '源文件缺失'
|
||||
};
|
||||
export const active = run => ['queued', 'running'].includes(run.state);
|
||||
export const enc = value => encodeURIComponent(value);
|
||||
export const scriptUrl = id => `/api/scripts/${enc(id)}`;
|
||||
export const runUrl = id => `/api/runs/${enc(id)}`;
|
||||
export const date = value => value ? new Date(value).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||||
export function bytes(value) {
|
||||
if (!Number.isFinite(Number(value))) return '—';
|
||||
const size = Number(value);
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
export function el(tag, attributes = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else if (key === 'text') node.textContent = value;
|
||||
else if (['checked', 'disabled', 'hidden', 'selected', 'value'].includes(key)) node[key] = value;
|
||||
else if (value !== undefined && value !== null) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child !== null && child !== undefined) node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
export const button = (text, onClick, style = 'secondary') => el('button', {type: 'button', class: `button ${style}`, onclick: onClick}, text);
|
||||
export const link = (text, href, className = '') => el('a', {href, class: className}, text);
|
||||
export const badge = (state, text = stateNames[state] || state) => el('span', {class: `badge ${state}`}, text);
|
||||
export const note = (text, tone = '') => el('div', {class: `notice ${tone}`}, text);
|
||||
export const empty = (title, detail) => el('div', {class: 'empty'}, el('span', {class: 'empty-icon', 'aria-hidden': 'true'}, '▤'), el('h3', {}, title), el('p', {}, detail));
|
||||
export const loading = (text = '正在加载…') => el('div', {class: 'loading', role: 'status'}, text);
|
||||
let fieldCounter = 0;
|
||||
export function field(text, control, hint = '') {
|
||||
const hintId = `field-hint-${++fieldCounter}`;
|
||||
control.setAttribute('aria-label', text);
|
||||
if (hint) control.setAttribute('aria-describedby', hintId);
|
||||
return el('label', {class: 'field'}, el('span', {class: 'field-label'}, text), control, hint ? el('small', {id: hintId}, hint) : null);
|
||||
}
|
||||
export const input = (attributes = {}) => el('input', {class: 'input', ...attributes});
|
||||
export const textarea = (attributes = {}) => el('textarea', {class: 'input', rows: 3, ...attributes});
|
||||
export const select = (label, options, value = '') => el('select', {class: 'input', 'aria-label': label}, options.map(([id, text]) => el('option', {value: id, selected: String(id) === String(value)}, text)));
|
||||
export function heading(title, subtitle, actions = []) {
|
||||
return el('div', {class: 'page-heading'}, el('div', {}, el('h1', {}, title), el('p', {}, subtitle)), el('div', {class: 'actions'}, actions));
|
||||
}
|
||||
export function kv(items) {
|
||||
return el('dl', {class: 'metadata'}, items.map(([label, value]) => [el('dt', {}, label), el('dd', {}, value ?? '—')]));
|
||||
}
|
||||
export function errorBox(error, retry) {
|
||||
return el('div', {class: 'notice danger', role: 'alert'}, el('span', {}, error.message || '请求失败,请稍后重试。'), retry ? button('重试', retry, 'small') : null);
|
||||
}
|
||||
export async function api(path, options = {}, signal) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener('abort', abort, {once: true});
|
||||
const timer = setTimeout(abort, options.body instanceof FormData ? 120000 : 20000);
|
||||
const headers = {...options.headers};
|
||||
if (options.method && options.method !== 'GET') headers['X-XAS-Console'] = '1';
|
||||
let body = options.body;
|
||||
if (body && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(path, {...options, body, headers, signal: controller.signal, credentials: 'same-origin'});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(typeof data?.detail === 'string' ? data.detail : `请求未完成(HTTP ${response.status})。`);
|
||||
if (data === null) throw new Error('服务器返回了无法读取的数据。');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
if (error.name === 'AbortError') throw new Error('请求超时,操作结果尚未确认。请先核对记录;运行请求重试会复用原编号。');
|
||||
if (error instanceof TypeError) throw new Error('无法连接控制台,请检查网络后重试。');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
let toastTimer;
|
||||
export function toast(text) {
|
||||
const target = document.querySelector('#toast');
|
||||
target.textContent = text;
|
||||
target.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { target.hidden = true; }, 5000);
|
||||
}
|
||||
export function dialog(title, content) {
|
||||
const close = button('关闭', () => panel.close(), 'small');
|
||||
close.setAttribute('aria-label', '关闭对话框');
|
||||
const panel = el('dialog', {class: 'dialog', 'aria-label': title}, el('div', {class: 'dialog-header'}, el('h2', {}, title), close), content);
|
||||
panel.addEventListener('close', () => panel.remove(), {once: true});
|
||||
document.body.append(panel);
|
||||
panel.showModal();
|
||||
return panel;
|
||||
}
|
||||
export function pageContext(main) {
|
||||
const controller = new AbortController();
|
||||
const timers = new Set();
|
||||
const context = {
|
||||
main, signal: controller.signal,
|
||||
get alive() { return !controller.signal.aborted; },
|
||||
show(...nodes) { if (context.alive) { main.replaceChildren(...nodes.filter(node => node !== null && node !== undefined)); main.setAttribute('aria-busy', 'false'); } },
|
||||
async repeat(callback, interval = 2500) {
|
||||
if (!context.alive) return;
|
||||
if (document.visibilityState === 'visible' && await callback() === false) return;
|
||||
if (context.alive) {
|
||||
const timer = setTimeout(() => { timers.delete(timer); context.repeat(callback, interval); }, interval);
|
||||
timers.add(timer);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
controller.abort();
|
||||
timers.forEach(clearTimeout);
|
||||
document.querySelectorAll('dialog').forEach(panel => panel.close());
|
||||
}
|
||||
};
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user