feat: initialize XAS test console with native execution

This commit is contained in:
AdamWen95
2026-10-14 09:38:58 +08:00
commit 438844b01a
83 changed files with 9334 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import {api, el, note, heading, errorBox, pageContext, bytes, date} from './ui.js';
import {scriptList, scriptDetail} from './scripts.js';
import {runList, runDetail} from './runs.js';
const main = document.querySelector('#main');
let context;
let summaryBusy = false;
async function summary() {
if (summaryBusy || document.visibilityState !== 'visible') return;
summaryBusy = true;
const health = document.querySelector('#worker-status');
const notice = document.querySelector('#global-notice');
try {
const data = await api('/api/summary');
document.querySelector('#nav-count').textContent = data.scripts;
document.querySelector('#summary').replaceChildren(...[
['测试项目', data.scripts, '按测试行为统计', ''], ['可运行', data.ready, '业务测试已就绪', 'accent'],
['进行中', data.active, '排队 / 执行', ''], ['运行记录', data.runs, `归档 ${bytes(data.storage.run_bytes)}`, '']
].map(([label, value, hint, style]) => el('div', {class: `stat ${style}`}, el('span', {class: 'stat-label'}, label), el('strong', {class: 'stat-value'}, value), el('span', {class: 'stat-foot'}, hint))));
health.replaceChildren(el('span', {class: `worker-light ${data.worker.online ? '' : 'offline'}`, 'aria-hidden': 'true'}), data.worker.online ? '执行器在线 · 单任务队列' : '执行器离线');
health.title = `最近心跳:${date(data.worker.last_seen)}`;
notice.hidden = data.worker.online;
notice.textContent = data.worker.online ? '' : `执行器当前离线,排队任务需要等待执行器恢复。最近心跳:${date(data.worker.last_seen)}。`;
} catch (error) {
health.replaceChildren(el('span', {class: 'worker-light offline', 'aria-hidden': 'true'}), '连接状态未知');
notice.hidden = false;
notice.textContent = `工作台状态更新失败:${error.message} 显示的数据可能已过期。`;
} finally { summaryBusy = false; }
}
async function route() {
context?.dispose();
context = pageContext(main);
main.setAttribute('aria-busy', 'true');
const value = location.hash.slice(1) || '/scripts';
const [path, query = ''] = value.split('?');
const segments = path.split('/').filter(Boolean);
const current = segments[0] || 'scripts';
document.querySelectorAll('[data-nav]').forEach(nav => {
if (nav.dataset.nav === current) nav.setAttribute('aria-current', 'page');
else nav.removeAttribute('aria-current');
});
document.title = `${current === 'runs' ? '运行记录' : '测试项目'} · XAS 测试控制台`;
const ctx = context;
summary();
try {
if (segments.length > 2) throw new Error('页面地址无效。');
const id = segments[1] ? decodeURIComponent(segments[1]) : null;
if (current === 'scripts') await (id ? scriptDetail(ctx, id) : scriptList(ctx));
else if (current === 'runs') await (id ? runDetail(ctx, id) : runList(ctx, new URLSearchParams(query).get('script_id') || ''));
else ctx.show(heading('找不到此页面', '请选择左侧的测试项目或运行记录。'), note('页面地址不存在。'));
} catch (error) { if (ctx.alive) ctx.show(errorBox(error, route)); }
}
document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); main.focus(); });
window.addEventListener('hashchange', route);
window.addEventListener('pagehide', () => context?.dispose());
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') summary(); });
setInterval(summary, 15000);
route();
+29
View File
@@ -0,0 +1,29 @@
export function parseLines(value) {
return value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
}
export function parseArgs(value) {
if (!value.trim().startsWith('[')) return parseLines(value);
let args;
try { args = JSON.parse(value); }
catch { throw new Error('参数 JSON 格式不正确,请填写字符串数组。'); }
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) {
throw new Error('参数必须是 JSON 字符串数组,或每行一个参数。');
}
return args;
}
export function appendLog(previous, incoming, limit = 240000) {
const all = previous + incoming;
return {text: all.slice(-limit), trimmed: all.length > limit};
}
export function requestId() {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
// LAN HTTP can lack randomUUID; getRandomValues remains available there.
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#14231e">
<title>XAS · 测试控制台</title>
<link rel="stylesheet" href="/static/styles.css">
<script type="module" src="/static/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main">跳转到内容</a>
<aside class="sidebar">
<a class="brand" href="#/scripts" aria-label="XAS 测试控制台首页">
<span class="brand-mark" aria-hidden="true">X</span>
<span><strong>XAS</strong><small>TEST CONSOLE</small></span>
</a>
<div class="sidebar-label">工作空间</div>
<nav aria-label="主导航">
<a href="#/scripts" data-nav="scripts"><span aria-hidden="true">▦</span>测试项目<span class="nav-count" id="nav-count">—</span></a>
<a href="#/runs" data-nav="runs"><span aria-hidden="true">↗</span>运行记录</a>
</nav>
<div class="sidebar-note"><span class="dot"></span>公司内网 · 共享工作台<p>从脚本到结果,每次运行都有迹可循。</p></div>
<div class="sidebar-bottom">新爱算 <span>测试与验证</span></div>
</aside>
<div class="workspace">
<header class="topbar"><div><span class="eyebrow">ENGINEERING /</span><span>测试控制台</span></div><div id="worker-status" role="status">正在连接执行器…</div></header>
<div id="summary" class="summary" aria-label="工作台概览"></div>
<div id="global-notice" class="global-notice" role="status" hidden></div>
<main id="main" tabindex="-1" aria-busy="true"><div class="loading">正在加载工作台…</div></main>
<footer class="page-footer"><span>脚本版本可追溯 · 运行结果独立归档</span><span>时间按本地时区显示</span></footer>
</div>
<div id="toast" class="toast" role="status" hidden></div>
<noscript><p class="noscript">请启用 JavaScript 以使用测试控制台。</p></noscript>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
import {api, el, button, badge, note, loading, field, input, select, kv, errorBox, dialog, scriptUrl, runUrl} from './ui.js';
export const isNative = record => record.execution?.mode === 'native';
export function maxTimeout(script) {
if (!isNative(script)) return 7200;
const limit = Number(script.execution.max_timeout_seconds);
return Number.isSafeInteger(limit) && limit >= 5 ? Math.min(limit, 259200) : 259200;
}
export function nativeOptions(parameters, values) {
const entries = [];
for (const parameter of parameters) {
const {key, label, type, required, min, max} = parameter;
const raw = values[key];
const blank = raw === undefined || raw === null || String(raw).trim() === '';
if (required && blank) throw new Error(`请填写${label}。`);
if (!['integer', 'number', 'select', 'text'].includes(type)) throw new Error(`${label}的参数类型不支持。`);
if (blank && type !== 'text') continue;
let value = raw ?? '';
if (type === 'integer' || type === 'number') {
value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${label}必须是有效数字。`);
if (type === 'integer' && !Number.isSafeInteger(value)) throw new Error(`${label}必须是整数。`);
if ((min !== undefined && value < min) || (max !== undefined && value > max)) throw new Error(`${label}超出允许范围。`);
} else if (type === 'select') {
const option = (parameter.options || []).find(item => String(item.value) === String(raw));
if (!option) throw new Error(`请选择有效的${label}。`);
value = option.value;
} else value = String(value);
entries.push([key, value]);
}
return Object.fromEntries(entries);
}
export function nativeParameterForm(script) {
const parameters = script.execution.parameters || [];
const saved = script.config.native_options || {};
const controls = new Map();
const node = el('div', {class: 'native-parameters'});
for (const parameter of parameters) {
const value = Object.hasOwn(saved, parameter.key) ? saved[parameter.key] : parameter.default;
let control;
if (parameter.type === 'select') {
const options = (parameter.options || []).map(item => [item.value, item.label]);
if (value === undefined || value === null || value === '' || !options.some(([id]) => String(id) === String(value))) options.unshift(['', '请选择…']);
control = select(parameter.label, options, value ?? '');
} else {
const numeric = ['integer', 'number'].includes(parameter.type);
control = input({type: numeric ? 'number' : 'text', value: value ?? '',
min: numeric ? parameter.min : undefined, max: numeric ? parameter.max : undefined,
step: parameter.type === 'integer' ? '1' : numeric ? 'any' : undefined,
autocomplete: 'off', spellcheck: 'false'});
}
if (parameter.required) control.required = true;
controls.set(parameter.key, control);
const bounds = parameter.min !== undefined || parameter.max !== undefined
? `允许范围:${parameter.min ?? '不限'} 至 ${parameter.max ?? '不限'}。` : '';
node.append(field(parameter.label, control, [parameter.help, bounds].filter(Boolean).join(' ')));
}
if (!parameters.length) node.append(note('此原环境方案使用固定参数。'));
return {node, read: () => nativeOptions(parameters, Object.fromEntries(Array.from(controls, ([key, control]) => [key, control.value])))};
}
function impactView(impact) {
if (!impact) return null;
return el('div', {class: `native-impact ${impact.requires_confirmation ? 'attention' : ''}`},
el('h3', {}, impact.title || '运行影响'),
(impact.details || []).length ? el('ul', {}, impact.details.map(detail => el('li', {}, detail))) : null,
el('p', {class: 'muted'}, impact.requires_confirmation ? '每次启动前须确认本次影响。' : '普通测试,无额外影响确认。'));
}
export function nativeScriptInfo(script) {
if (!isNative(script)) return null;
const execution = script.execution;
return el('section', {class: 'panel native-plan'}, el('div', {class: 'panel-header'}, el('h2', {}, '原环境执行方案'), badge('running', '原环境')),
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['解释器', execution.interpreter], ['方案编号', execution.profile_id],
['交互方式', execution.interactive ? '原菜单 · 可发送菜单选项' : '自动执行']]),
execution.description ? el('p', {class: 'native-description'}, execution.description) : null,
impactView(execution.impact), note('启动方案已接入,未完成本轮业务验收。', 'info'));
}
export async function preflightNativeRun(ctx, script) {
const content = el('div', {}, loading('正在只读预检原环境…'));
const panel = dialog('原环境运行预检', content);
panel.classList.add('native-dialog');
let plan;
try {
plan = await api(`${scriptUrl(script.id)}/preflight`, {method: 'POST', body: {}}, ctx.signal);
if (!ctx.alive || !panel.open) return null;
const command = el('pre', {class: 'native-command mono', tabindex: '0', 'aria-label': '本次启动命令'}, plan.command_preview || '未生成启动命令');
content.replaceChildren(...[note(plan.ready ? '只读预检通过,原脚本尚未执行。' : '预检未通过,本次不会执行。', plan.ready ? 'success' : 'danger'),
kv([['执行模式', plan.mode === 'native' ? '原环境' : plan.mode], ['运行账号', plan.user], ['原工作目录', plan.cwd]]),
el('h3', {class: 'native-subtitle'}, '本次启动命令'), command,
el('ul', {class: 'preflight-checks'}, (plan.checks || []).map(check => el('li', {},
badge(check.ok ? 'ready' : 'failed', check.ok ? '通过' : '未通过'), el('div', {}, el('strong', {}, check.name), el('p', {}, check.message || ''))))),
impactView(plan.impact),
(plan.output_paths || []).length ? el('details', {class: 'disclosure'}, el('summary', {}, '查看原输出规则'), el('pre', {}, plan.output_paths.join('\n'))) : null,
...(plan.notes || []).map(text => note(text, 'info'))].filter(node => node !== null));
if (!plan.ready) return null;
if (plan.mode !== 'native' || typeof plan.plan_token !== 'string' || !plan.plan_token) throw new Error('预检未返回有效的原环境计划,请关闭窗口后重新预检。');
if (!plan.impact?.requires_confirmation) {
content.append(note('正在创建本次运行记录…', 'info'));
return {payload: {plan_token: plan.plan_token}, close: () => panel.close()};
}
} catch (error) {
if (ctx.alive && panel.open) content.replaceChildren(errorBox(error), note('本次没有提交运行。关闭窗口后可重新预检。'));
return null;
}
return new Promise(resolve => {
let acknowledged = false;
const confirm = button('确认影响并开始本次运行', () => {
if (!ctx.alive || !panel.open || acknowledged) return;
acknowledged = true;
confirm.disabled = true;
panel.close();
resolve({payload: {plan_token: plan.plan_token, impact_acknowledged: true}, close: () => {}});
}, 'primary');
panel.addEventListener('close', () => { if (!acknowledged) resolve(null); }, {once: true});
content.append(note('确认仅适用于上面显示的源码、参数和方案;每次重新启动都会重新预检。'),
el('div', {class: 'form-footer'}, button('暂不运行', () => panel.close()), confirm));
});
}
export function validateMenuInput(text) {
if (text.includes('\0')) throw new Error('菜单输入不能包含 NUL 字符。');
if (new TextEncoder().encode(text).length > 1024) throw new Error('单次菜单输入不能超过 1024 UTF-8 字节。');
if (!/^(?:[0-9 ,:./+\-]*|[yYnNqQ])$/.test(text)) throw new Error('仅支持数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据。');
}
export function nativeRunPanel(ctx, initialRun) {
let current = initialRun;
let blocked = false;
let sending = false;
let executionKey = '';
const details = el('div');
const control = input({type: 'text', autocomplete: 'off', spellcheck: 'false', maxlength: '1024', placeholder: '输入原菜单选项后发送'});
const feedback = el('div', {'aria-live': 'polite'});
const submit = el('button', {type: 'submit', class: 'button primary'}, '发送菜单选项');
const menu = el('form', {class: 'native-menu', autocomplete: 'off', hidden: true},
el('h3', {}, '原菜单输入'), note('仅支持数字选项、数字列表或 y/n/q,不接受命令和控制键。不要输入密码或令牌;发送后清空,不保存输入历史。'),
field('菜单选项', control, '留空发送回车以接受原菜单默认值;单次最多 1024 UTF-8 字节。'),
feedback, el('div', {class: 'form-footer'}, submit));
const node = el('section', {class: 'panel native-plan'}, details, menu);
const eligible = () => isNative(current) && current.execution.interactive && current.state === 'running';
function update(run, options = {}) {
current = run;
blocked = Boolean(options.blocked);
const execution = run.execution || {mode: 'container'};
const key = JSON.stringify(execution);
if (key !== executionKey) {
executionKey = key;
details.replaceChildren(el('div', {class: 'panel-header'}, el('h2', {}, '执行环境'), badge('running', isNative(run) ? '原环境' : '隔离容器')));
if (isNative(run)) details.append(...[
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['方案编号', execution.profile_id],
['影响确认', execution.impact?.requires_confirmation ? execution.impact_acknowledged ? '已确认本次影响' : '未记录确认' : '无需额外确认']]),
el('h3', {class: 'native-subtitle'}, '实际启动命令'), el('pre', {class: 'native-command mono', tabindex: '0'}, execution.command_preview || '尚未记录启动命令'),
impactView(execution.impact),
execution.verification ? el('details', {class: 'disclosure'}, el('summary', {}, '查看执行核对信息'), el('pre', {}, JSON.stringify(execution.verification, null, 2))) : null].filter(node => node !== null));
else details.append(el('p', {class: 'muted'}, '本次运行使用隔离容器。'));
}
menu.hidden = !eligible();
if (!eligible()) control.value = '';
control.disabled = blocked || sending || !eligible();
submit.disabled = control.disabled;
}
menu.addEventListener('submit', async event => {
event.preventDefault();
if (!ctx.alive || sending || blocked || !eligible()) return;
let text = control.value;
control.value = '';
feedback.replaceChildren();
try { validateMenuInput(text); }
catch (error) { feedback.replaceChildren(errorBox(error)); return; }
sending = true;
update(current, {blocked});
try {
const response = api(`${runUrl(current.id)}/input`, {method: 'POST', body: {text}}, ctx.signal);
text = '';
const result = await response;
if (ctx.alive) feedback.replaceChildren(note(result.accepted === true ? '输入已送达本次运行。' : '输入结果未确认,请先核对当前状态和日志。', result.accepted === true ? 'success' : ''));
} catch {
if (ctx.alive) feedback.replaceChildren(note('输入未送达或结果尚未确认。请核对运行状态和最新日志,再决定是否重新输入。', 'danger'));
} finally { text = ''; sending = false; if (ctx.alive) update(current, {blocked}); }
});
update(initialRun);
return {node, update};
}
+63
View File
@@ -0,0 +1,63 @@
import {api, el, select, input, loading, note, errorBox, link, scriptUrl} from './ui.js';
const roles = {entry: '执行入口', support: '配套文件', self_test: '套件内部验证', variant: '相关实现 / 场景'};
export function relatedFiles(ctx, script) {
const content = el('div', {class: 'related-files'});
const panel = el('details', {class: 'panel disclosure'},
el('summary', {}, `相关文件 · ${script.file_count || 1} 个`), content);
let loaded = false;
let busy = false;
async function load() {
if (busy || loaded || !panel.open) return;
busy = true;
content.replaceChildren(loading('正在读取项目文件…'));
try {
const {items} = await api(`${scriptUrl(script.id)}/files`, {}, ctx.signal);
if (!ctx.alive) return;
const search = input({type: 'search', placeholder: '按文件名或路径查找', 'aria-label': '搜索相关文件'});
const choice = select('查看相关文件', []);
const info = el('p', {class: 'source-info mono'});
const feedback = el('div');
const download = link('下载此文件', '', 'button small');
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '相关文件源代码,只读'});
content.replaceChildren(note('这里展示测试入口、配套模块和相关实现。查看文件不会改变本次测试的执行入口。'),
search, choice, info, download, feedback, code);
let generation = 0;
async function show() {
const ticket = ++generation;
const file = items.find(item => item.id === choice.value);
download.hidden = true;
feedback.replaceChildren();
info.textContent = file ? `${roles[file.role] || '配套文件'} · ${file.source_path}` : '';
code.textContent = file ? '正在读取…' : '没有匹配的文件。';
if (!file) return;
try {
const data = await api(`${scriptUrl(file.id)}/code`, {}, ctx.signal);
if (!ctx.alive || ticket !== generation) return;
code.textContent = data.code || '(空文件)';
download.href = `${scriptUrl(file.id)}/download`;
download.hidden = Boolean(data.redacted);
if (data.redacted) feedback.append(note('疑似凭据已隐藏,该文件不可下载。'));
} catch (error) {
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, show)); }
}
}
function filter() {
const term = search.value.toLowerCase();
const selected = choice.value;
const matching = items.filter(file => (file.name + file.source_path).toLowerCase().includes(term));
choice.replaceChildren(...matching.map(file => el('option', {value: file.id}, `${roles[file.role]} · ${file.relative_path}`)));
if (matching.some(file => file.id === selected)) choice.value = selected;
show();
}
choice.addEventListener('change', show);
search.addEventListener('input', filter);
loaded = true;
filter();
} catch (error) { if (ctx.alive) content.replaceChildren(errorBox(error, load)); }
finally { busy = false; }
}
panel.addEventListener('toggle', load);
return panel;
}
+184
View File
@@ -0,0 +1,184 @@
import {api, el, button, link, badge, note, empty, loading, select, heading, kv, errorBox, dialog, date, bytes, enc, runUrl, active} from './ui.js';
import {appendLog} from './core.js';
import {isNative, nativeRunPanel} from './native-ui.js';
export function runTable(items) {
return el('div', {class: 'table-wrap'}, el('table', {},
el('thead', {}, el('tr', {}, ['脚本 / 运行编号', '状态', '版本', '创建时间', '退出码', ''].map(title => el('th', {scope: 'col'}, title)))),
el('tbody', {}, items.map(run => el('tr', {class: 'run-row'},
el('td', {}, el('span', {class: 'run-name'}, run.script_name), el('div', {class: 'source-info mono'}, run.id)),
el('td', {}, badge(run.state), run.synthetic ? badge('synthetic', '模拟验收') : null),
el('td', {class: 'mono'}, `v${run.version}`), el('td', {}, date(run.created_at)),
el('td', {class: 'mono'}, run.exit_code ?? '—'), el('td', {}, el('a', {
href: `#/runs/${enc(run.id)}`, class: 'button detail-link run-open',
'aria-label': `查看运行详情:${run.script_name},${run.id}`}, '查看详情 →')))))));
}
export async function runList(ctx, scriptId = '') {
const status = select('运行状态', [['', '全部状态'], ['running', '运行中'], ['queued', '排队中'], ['succeeded', '进程成功'], ['failed', '失败'], ['cancelled', '已取消'], ['timed_out', '已超时'], ['interrupted', '已中断']]);
const origin = select('记录类型', [['', '全部记录'], ['real', '普通脚本运行'], ['synthetic', '模拟验收']]);
const results = el('div', {}, loading('正在读取运行记录…'));
const feedback = el('div', {'aria-live': 'polite'});
const updated = el('span', {class: 'muted'});
let items = [];
let busy = false;
const refresh = button('↻ 刷新', () => update());
ctx.show(heading('运行记录', scriptId ? '查看此脚本最近 100 次运行的状态、日志与结果。' : '查看最近 100 次运行。每次执行使用固定版本和独立结果目录。',
[scriptId ? link('查看全部记录', '#/runs', 'button') : null, refresh]),
el('div', {class: 'filters'}, status, origin, scriptId ? link('返回脚本', `#/scripts/${enc(scriptId)}`, 'button small') : null),
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '退出码 0 表示进程正常结束;业务结论以脚本报告为准。'), updated), feedback, results);
const render = () => {
const filtered = items.filter(run => (!status.value || run.state === status.value)
&& (!origin.value || Boolean(run.synthetic) === (origin.value === 'synthetic')));
results.replaceChildren(filtered.length ? runTable(filtered) : empty('没有符合条件的运行记录', '调整筛选条件,或从脚本详情开始一次运行。'));
};
async function update() {
if (busy) return;
busy = true;
refresh.disabled = true;
try {
const data = await api(`/api/runs?limit=100${scriptId ? `&script_id=${enc(scriptId)}` : ''}`, {}, ctx.signal);
if (!ctx.alive) return;
items = data.items;
render();
feedback.replaceChildren();
updated.textContent = `更新于 ${new Date().toLocaleTimeString('zh-CN', {hour12: false})}`;
} catch (error) {
if (ctx.alive) { feedback.replaceChildren(errorBox(error, update)); if (!items.length) results.replaceChildren(); }
} finally { busy = false; refresh.disabled = false; }
}
[status, origin].forEach(control => control.addEventListener('change', render));
ctx.repeat(update, 6000);
}
export async function runDetail(ctx, id) {
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回运行记录', '#/runs', 'button back-button'));
ctx.show(navigation(), loading('正在读取运行状态…'));
let run;
try { run = await api(runUrl(id), {}, ctx.signal); }
catch (error) { if (ctx.alive) ctx.show(navigation(), heading('运行详情', '运行记录读取失败。'), errorBox(error, () => runDetail(ctx, id))); return; }
if (!ctx.alive) return;
let offset = 0;
let logText = '';
let retainedTrimmed = false;
let serverTruncated = false;
let busy = false;
let logDone = false;
let cancelling = false;
let cancelRequested = false;
let mutation = 0;
let artifactsKey = '';
const feedback = el('div', {'aria-live': 'polite'});
const execution = nativeRunPanel(ctx, run);
const status = el('div', {class: 'actions'});
const metadata = el('div', {class: 'run-summary'});
const message = el('div');
const log = el('pre', {class: 'log', tabindex: '0', 'aria-label': '运行标准输出与错误日志'}, '正在读取日志…');
const logStatus = el('span', {}, '等待日志');
const logHint = el('span', {}, '标准输出 / 标准错误');
const follow = el('input', {type: 'checkbox', checked: true});
const files = el('div', {class: 'artifact-list'});
const retry = button('立即刷新', () => tick(), 'small');
const cancel = button('取消运行', async () => {
if (cancelling || !active(run)) return;
cancelling = true;
execution.update(run, {blocked: true});
mutation++;
cancel.disabled = true;
cancel.textContent = '正在取消…';
try {
run = await api(`${runUrl(id)}/cancel`, {method: 'POST'}, ctx.signal);
cancelRequested = true;
if (ctx.alive) { feedback.replaceChildren(); renderRun(); }
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
finally { cancelling = false; if (ctx.alive) { cancel.textContent = '取消运行'; cancel.disabled = !active(run); execution.update(run, {blocked: cancelRequested}); } }
}, 'danger');
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('运行记录', '#/runs'), ' / ', id),
heading(run.script_name, `运行 ${id}`, [link('查看测试项目', `#/scripts/${enc(run.script_id)}`, 'button back-button'), cancel]),
feedback, el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行概况'), status), metadata, message,
kv([['脚本版本', `v${run.version}`], ['源码摘要', run.sha256], ['创建时间', date(run.created_at)], ['请求来源', run.source_ip || '—']]),
el('details', {class: 'disclosure'}, el('summary', {}, '查看本次运行配置快照'), el('pre', {}, JSON.stringify(run.config, null, 2)))),
execution.node,
el('section', {class: 'panel log-panel'}, el('div', {class: 'log-toolbar'}, el('h2', {}, '实时日志'),
el('div', {class: 'actions'}, el('label', {}, follow, '跟随最新日志'), retry, link('下载完整日志', `${runUrl(id)}/log/download`, 'button small'))),
log, el('div', {class: 'log-hint'}, logStatus, logHint)),
el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '结果文件')), files),
note(isNative(run) ? '取消与超时仅处理本次原环境任务的本机进程。原脚本通过 Docker/tmux 启动的任务可能仍在运行;请核对收尾记录,不能据此认定原模型已恢复。'
: '取消与超时只终止本次执行容器,不能证明外部模型服务已停止推理。'));
function renderRun() {
execution.update(run, {blocked: cancelling || cancelRequested});
status.replaceChildren(badge(run.state));
if (run.synthetic) status.append(badge('synthetic', '模拟验收'));
cancel.hidden = !active(run);
cancel.disabled = cancelling || !active(run);
metadata.replaceChildren(...[
['开始时间', date(run.started_at)], ['结束时间', date(run.finished_at)],
['退出码', run.exit_code ?? '—'], ['执行类型', run.synthetic ? '模拟验收' : '脚本测试']
].map(([label, value]) => el('div', {}, el('small', {}, label), el('strong', {}, value))));
message.replaceChildren();
if (run.message) message.append(note(run.message, ['failed', 'timed_out', 'interrupted'].includes(run.state) ? 'danger' : 'info'));
if (run.state === 'succeeded') message.append(note('进程正常结束。是否达到测试目标,请查看脚本产生的报告。', 'success'));
if (run.synthetic) message.append(note('这是模拟验收记录,不代表真实模型服务或测试业务通过。', 'info'));
const key = JSON.stringify([run.state, run.artifacts]);
if (key !== artifactsKey) {
artifactsKey = key;
files.replaceChildren(...((run.artifacts || []).length ? run.artifacts.map(artifact => artifactRow(ctx, run, artifact)) :
[empty(active(run) ? '结果文件等待归档' : '本次运行没有归档文件', active(run) ? '执行结束后,登记的结果文件会出现在这里。' : '脚本可能没有生成文件,或输出路径未登记;请查看日志和运行配置。')]));
}
}
async function tick() {
if (busy || cancelling) return;
if (!active(run) && logDone) return false;
busy = true;
retry.disabled = true;
const ticket = mutation;
try {
const [current, chunk] = await Promise.all([
api(runUrl(id), {}, ctx.signal), api(`${runUrl(id)}/log?offset=${offset}`, {}, ctx.signal)
]);
if (!ctx.alive) return;
if (ticket !== mutation) return true;
run = current;
if (!Number.isSafeInteger(chunk.next_offset) || chunk.next_offset < offset) throw new Error('日志游标异常,请重新打开此运行。');
if (chunk.next_offset > offset) {
const retained = appendLog(logText, chunk.text);
logText = retained.text;
retainedTrimmed ||= retained.trimmed;
}
offset = chunk.next_offset;
logDone = chunk.done;
serverTruncated ||= chunk.truncated;
log.textContent = logText || (active(run) ? '等待脚本输出…' : '本次运行没有标准输出或标准错误。');
if (follow.checked) log.scrollTop = log.scrollHeight;
logStatus.textContent = active(run) ? (run.state === 'queued' ? '已排队 · 等待执行器' : '持续更新中 · 每 2.5 秒刷新') : logDone ? '日志读取完成' : '正在读取剩余日志…';
logHint.textContent = serverTruncated ? '日志达到服务端限制,后续输出可能未保留。' : retainedTrimmed ? '页面仅显示最近 240,000 字符;完整内容请下载日志。' : `已读取 ${bytes(offset)}`;
feedback.replaceChildren();
renderRun();
return active(run) || !logDone;
} catch (error) {
if (ctx.alive) { execution.update(run, {blocked: true}); feedback.replaceChildren(errorBox(error, tick)); logStatus.textContent = '连接暂时中断,保留已读取日志并继续重试'; }
} finally { busy = false; retry.disabled = false; }
}
follow.addEventListener('change', () => { if (follow.checked) log.scrollTop = log.scrollHeight; });
renderRun();
ctx.repeat(tick);
}
function artifactRow(ctx, run, artifact) {
const url = `${runUrl(run.id)}/artifacts/${enc(artifact.id)}`;
const actions = [link('下载', url, 'button small')];
if (['text', 'json'].includes(artifact.kind)) actions.unshift(button('预览', async () => {
const content = el('div', {}, loading('正在读取文件…'));
const panel = dialog(artifact.name, content);
try {
const preview = await api(`${url}/preview`, {}, ctx.signal);
if (!ctx.alive || !panel.open) return;
content.replaceChildren(el('pre', {class: 'code', tabindex: '0'}, preview.text || '(空文件)'));
if (preview.truncated) content.prepend(note('仅显示文件开头部分,请下载查看完整内容。'));
} catch (error) { if (ctx.alive && panel.open) content.replaceChildren(errorBox(error)); }
}, 'small'));
return el('div', {class: 'artifact'}, el('div', {class: 'artifact-name'}, artifact.name,
el('small', {}, `${bytes(artifact.size)} · ${artifact.kind === 'json' ? 'JSON' : artifact.kind === 'text' ? '文本' : '文件'}`)), el('div', {class: 'actions'}, actions));
}
+275
View File
@@ -0,0 +1,275 @@
import {api, el, button, link, badge, note, empty, loading, field, input, textarea, select, heading, kv, errorBox, toast, dialog, date, enc, scriptUrl} from './ui.js';
import {parseArgs, parseLines, requestId} from './core.js';
import {runTable} from './runs.js';
import {relatedFiles} from './related-files.js';
import {isNative, maxTimeout, nativeParameterForm, nativeScriptInfo, preflightNativeRun} from './native-ui.js';
const pendingRuns = new Map();
function pendingRequest(id) {
if (pendingRuns.has(id)) return pendingRuns.get(id);
let value;
try { value = sessionStorage.getItem(`xas.pending-run.${id}`); } catch { /* Private browser settings can disable storage. */ }
if (!value || !/^[0-9a-f-]{36}$/.test(value)) value = requestId();
pendingRuns.set(id, value);
try { sessionStorage.setItem(`xas.pending-run.${id}`, value); } catch { /* The in-memory request still supports safe retry. */ }
return value;
}
function clearRequest(id) {
pendingRuns.delete(id);
try { sessionStorage.removeItem(`xas.pending-run.${id}`); } catch { /* Storage may be unavailable. */ }
}
const scriptState = script => !script.enabled && script.status === 'ready' ? 'disabled' : script.status;
const scriptBadge = script => badge(scriptState(script), scriptState(script) === 'disabled' ? '未启用' : undefined);
const sourceLabel = script => script.kind === 'managed' ? '托管脚本' : '外部原文件';
const profileLabel = script => isNative(script) ? '服务器原环境' : script.config.profile === 'legacy_smoke' ? '旧版 Smoke 兼容环境' : '标准运行环境';
const typeLabel = script => script.category === 'example' ? '验收示例' : script.test_kind === 'suite' ? '测试套件' : '单项测试';
const groupLabels = new Map([
['uploaded', '上传的脚本'], ['company-gpu-suite', '公司 GPU 测试套件'],
['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)']
]);
export async function scriptList(ctx) {
ctx.show(heading('测试项目', '按实际测试行为管理,一项对应一次完整测试。'), loading());
try {
const {items} = await api('/api/scripts?include_examples=true', {}, ctx.signal);
if (!ctx.alive) return;
const search = input({type: 'search', placeholder: '搜索测试名称、用途或入口', 'aria-label': '搜索测试'});
const group = select('来源目录', [['', '全部来源'], ...Array.from(new Set(items.map(item => item.source_group || '未分组'))).sort().map(name => [name, groupLabels.get(name) || name])]);
const status = select('脚本状态', [['', '全部状态'], ['ready', '可运行'], ['needs_configuration', '待配置'], ['missing', '源文件缺失'], ['disabled', '未启用']]);
const kind = select('测试类型', [['', '全部类型'], ['suite', '测试套件'], ['test', '单项测试']]);
const purpose = select('项目用途', [['test', '业务测试'], ['example', '验收示例'], ['', '全部项目']], 'test');
const results = el('div');
const count = el('span', {class: 'muted', role: 'status'});
const discover = button('↻ 刷新来源', async () => {
discover.disabled = true;
try {
const result = await api('/api/discover', {method: 'POST'}, ctx.signal);
if (ctx.alive) {
toast(`目录已刷新:${result.tests} 项测试。内部文件更新不会自动新增测试项目。`);
await scriptList(ctx);
}
} catch (error) { if (ctx.alive) { toast(error.message); discover.disabled = false; } }
});
ctx.show(heading('测试项目', '一项对应一次测试;套件内部的多个脚本统一在详情中查看。', [discover, button('+ 新增测试', () => uploadDialog(ctx), 'primary')]),
el('div', {class: 'filters'}, el('div', {class: 'search'}, search), purpose, kind, status, group),
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '按测试行为组织 · 验收示例默认隐藏'), count), results);
const render = () => {
const term = search.value.trim().toLowerCase();
const filtered = items.filter(item => (!group.value || (item.source_group || '未分组') === group.value)
&& (!status.value || scriptState(item) === status.value) && (!kind.value || item.test_kind === kind.value)
&& (!purpose.value || item.category === purpose.value)
&& (!term || [item.name, item.description, item.source_path].some(value => (value || '').toLowerCase().includes(term))));
count.textContent = `${filtered.length} 项测试`;
results.replaceChildren();
if (!filtered.length) { results.append(empty(items.length ? '没有匹配的测试项目' : '从第一个测试开始', items.length ? '调整关键词或筛选条件。' : '上传一个测试的入口脚本,创建测试项目。')); return; }
const groups = new Map();
for (const item of filtered) {
const name = item.category === 'example' ? '控制台验收示例' : item.test_kind === 'suite' ? '测试套件' : '单项测试';
if (!groups.has(name)) groups.set(name, []);
groups.get(name).push(item);
}
for (const [name, scripts] of groups) results.append(el('section', {},
el('div', {class: 'section-title'}, el('h2', {}, name, el('span', {class: 'count'}, `${scripts.length} 项`))),
el('div', {class: 'catalog-grid'}, scripts.map(scriptCard))));
};
[search, group, status, kind, purpose].forEach(control => control.addEventListener('input', render));
render();
} catch (error) { if (ctx.alive) ctx.show(heading('测试项目', '查看、配置与运行测试。'), errorBox(error, () => scriptList(ctx))); }
}
function scriptCard(script) {
return el('a', {class: 'script-card', href: `#/scripts/${enc(script.id)}`, 'aria-label': script.name},
el('div', {class: 'card-top'}, el('span', {class: `language ${script.language}`}, typeLabel(script)), scriptBadge(script)),
el('h3', {}, script.name),
el('p', {class: 'script-description'}, script.description || '尚未填写说明。'),
el('p', {class: 'card-source', title: script.source_path}, script.source_path),
script.reason ? el('p', {class: 'card-reason'}, script.reason) : null,
el('div', {class: 'card-bottom'}, el('span', {}, `${script.file_count} 个相关文件 · v${script.version}`), el('span', {class: 'button detail-link'}, '查看项目详情 →')));
}
function uploadDialog(ctx, script) {
const file = input({type: 'file', accept: '.py,.sh', required: '', name: 'file'});
const name = input({name: 'name', required: '', maxlength: 120, placeholder: '例如:模型接口连通性检查'});
const description = textarea({name: 'description', placeholder: '说明测试目标、运行条件和输出结果'});
const feedback = el('div', {'aria-live': 'polite'});
const submit = el('button', {class: 'button primary', type: 'submit'}, script ? '上传新版本' : '创建脚本');
const form = el('form', {}, note(script ? '新版本单独保存,不覆盖历史源码。后续运行使用最新版本,已有运行记录保持原版本。' : '支持 Python 和 Shell。脚本代码只读;后续修改请上传新版本。'),
field('脚本文件', file, '选择 .py 或 .sh 文件。'),
script ? null : field('脚本名称', name), script ? null : field('说明', description), feedback,
el('div', {class: 'form-footer'}, submit));
const panel = dialog(script ? `上传新版本 · ${script.name}` : '上传新脚本', form);
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled || !form.reportValidity()) return;
const data = new FormData(form);
submit.disabled = true;
submit.textContent = '正在上传…';
feedback.replaceChildren();
try {
const saved = await api(script ? `${scriptUrl(script.id)}/versions` : '/api/scripts/upload', {method: 'POST', body: data}, ctx.signal);
if (!ctx.alive) return;
panel.close();
toast(script ? '新版本已保存。' : '脚本已创建。');
if (location.hash === `#/scripts/${enc(saved.id)}`) await scriptDetail(ctx, saved.id);
else location.hash = `/scripts/${enc(saved.id)}`;
} catch (error) {
if (ctx.alive) feedback.replaceChildren(errorBox(error), note('如果连接中断,请先关闭窗口并刷新脚本库,核对是否已保存,再决定重新上传。'));
} finally { submit.disabled = false; submit.textContent = script ? '上传新版本' : '创建脚本'; }
});
}
export async function scriptDetail(ctx, id) {
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回测试项目', '#/scripts', 'button back-button'));
ctx.show(navigation(), loading('正在读取脚本与版本…'));
try {
const script = await api(scriptUrl(id), {}, ctx.signal);
if (!ctx.alive) return;
if (!script.is_test) {
const source = el('section', {class: 'panel'});
ctx.show(navigation(), heading(script.name, '这是测试内部的源码文件。'), note('请从测试项目的执行入口配置和运行。'), source);
sourcePanel(ctx, source, script);
return;
}
let dirty = false;
const feedback = el('div', {'aria-live': 'polite'});
const start = button('▶ 开始运行', async () => {
if (start.disabled) return;
start.disabled = true;
start.textContent = '正在提交…';
feedback.replaceChildren();
let prepared;
try {
if (isNative(script)) {
start.textContent = '正在预检…';
prepared = await preflightNativeRun(ctx, script);
if (!prepared || !ctx.alive || dirty) return;
start.textContent = '正在提交…';
}
const run = await api(`${scriptUrl(id)}/runs`, {method: 'POST', body: {request_id: pendingRequest(id), ...prepared?.payload}}, ctx.signal);
clearRequest(id);
if (ctx.alive) location.hash = `/runs/${enc(run.id)}`;
} catch (error) {
if (ctx.alive) feedback.replaceChildren(errorBox(error), note(isNative(script)
? '再次点击会重新预检并重新确认影响,同时复用本次请求编号核对原记录,避免重复排队。'
: '再次点击会复用本次请求编号,核对原运行记录,避免重复排队。'));
} finally {
prepared?.close();
if (ctx.alive) { start.disabled = dirty || !script.enabled || script.status !== 'ready'; start.textContent = '▶ 开始运行'; }
}
}, 'primary');
start.disabled = !script.enabled || script.status !== 'ready';
const actions = [link('运行记录', `#/runs?script_id=${enc(id)}`, 'button')];
if (script.kind === 'managed') actions.push(button('上传新版本', () => uploadDialog(ctx, script)));
actions.push(start);
const source = el('section', {class: 'panel'});
const history = el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '近期运行')), loading());
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('测试项目', '#/scripts'), ' / ', script.name),
heading(script.name, script.description || '查看源代码、配置参数并开始一次独立运行。', actions),
feedback,
script.reason ? note(script.reason, script.status === 'missing' ? 'danger' : '') : null,
!script.enabled ? note('当前脚本未启用。确认运行参数和环境要求后,在配置中启用并保存。') : null,
nativeScriptInfo(script),
el('div', {class: 'detail-grid'}, source, configForm(ctx, script, () => {
dirty = true;
start.disabled = true;
start.title = '请先保存修改后的运行配置。';
})),
relatedFiles(ctx, script), el('div', {class: 'section-title'}, el('h2', {}, '执行记录')), history);
sourcePanel(ctx, source, script);
const loadHistory = async () => {
try {
const {items} = await api(`/api/runs?script_id=${enc(id)}&limit=10`, {}, ctx.signal);
if (ctx.alive) history.replaceChildren(items.length ? runTable(items) : empty('还没有运行记录', '首次运行后,可在这里回看状态、日志和结果文件。'));
} catch (error) { if (ctx.alive) history.replaceChildren(errorBox(error, loadHistory)); }
};
await loadHistory();
} catch (error) { if (ctx.alive) ctx.show(navigation(), heading('脚本详情', '读取脚本信息失败。'), errorBox(error, () => scriptDetail(ctx, id))); }
}
function sourcePanel(ctx, target, script) {
const versions = script.versions?.length ? script.versions : [{version: script.version}];
const version = select('查看源码版本', versions.map(item => [item.version, `版本 ${item.version}${String(item.version) === String(script.version) ? ' · 当前' : ''}`]), script.version);
const download = link('下载源码', '', 'button small');
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '脚本源代码,只读'}, '正在读取源码…');
const info = el('div', {class: 'source-info mono'});
const feedback = el('div');
target.append(el('div', {class: 'panel-header'}, el('h2', {}, script.is_test ? '执行入口(代码只读)' : '内部源码(只读)'), scriptBadge(script)),
kv([['来源类型', sourceLabel(script)], ['源文件', script.source_path], ['运行环境', profileLabel(script)], ['更新时间', date(script.updated_at)]]),
el('div', {class: 'source-header'}, version, download), info, feedback, code,
el('p', {class: 'source-info'}, '代码只读。查看历史版本不改变运行版本;“开始运行”使用当前已保存版本与配置。'));
let generation = 0;
const load = async () => {
const ticket = ++generation;
code.textContent = '正在读取源码…';
download.hidden = true;
info.textContent = '';
feedback.replaceChildren();
try {
const data = await api(`${scriptUrl(script.id)}/code?version=${enc(version.value)}`, {}, ctx.signal);
if (!ctx.alive || ticket !== generation) return;
code.textContent = data.code || '(空文件)';
info.textContent = `${data.filename} · v${version.value}\nSHA-256 ${data.sha256}`;
download.href = `${scriptUrl(script.id)}/download?version=${enc(version.value)}`;
download.hidden = Boolean(data.redacted);
if (data.redacted) feedback.append(note('源码中包含敏感内容,预览已脱敏,下载不可用。'));
if (data.truncated) feedback.append(note(data.redacted ? '文件较大,当前仅显示部分已脱敏源码。' : '文件较大,当前仅显示部分源码。可下载完整文件。'));
} catch (error) {
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, load)); }
}
};
version.addEventListener('change', load);
load();
}
function configForm(ctx, script, onDirty) {
const config = script.config;
const native = isNative(script);
const limit = maxTimeout(script);
const parameters = native ? nativeParameterForm(script) : null;
const name = input({value: script.name, required: '', maxlength: 120});
const description = textarea({value: script.description || ''});
const args = textarea({value: JSON.stringify(config.args, null, 2), rows: 4, class: 'input mono', spellcheck: 'false'});
const cwd = input({value: native ? script.execution.cwd || config.cwd || '' : config.cwd || '', placeholder: script.kind === 'managed' ? '留空:使用本次运行目录' : '原脚本工作目录'});
const outputs = textarea({value: (config.output_paths || []).join('\n'), class: 'input mono', spellcheck: 'false', placeholder: '每行一个日志或结果路径'});
if (native) [args, cwd, outputs].forEach(control => control.setAttribute('readonly', ''));
const timeout = input({type: 'number', value: config.timeout_seconds, required: '', min: '5', max: String(limit), step: '1'});
const enabled = input({type: 'checkbox', checked: script.enabled});
const feedback = el('div', {'aria-live': 'polite'});
const submit = el('button', {type: 'submit', class: 'button primary'}, '保存配置');
const form = el('form', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行配置')),
field('脚本名称', name), field('说明', description), parameters?.node,
field(native ? '原入口参数(只读)' : '参数', args, native ? '参数由原环境方案计算;可调整的选项在上方填写,实际命令以本次预检为准。' : 'JSON 字符串数组,或每行一个参数;不会交给 shell 拼接。'),
field(native ? '原工作目录(只读)' : '工作目录', cwd, native ? '固定使用登记的原目录。' : script.kind === 'managed' ? '托管脚本留空。可通过 XAS_RUN_DIR 获取本次结果目录。' : '必须位于已登记的来源目录内。'),
field(native ? '原输出规则(只读)' : '归档输出路径', outputs, native ? '由固定方案管理;运行前预检显示本次输出规则,保留原脚本的输出行为。' : script.kind === 'managed' ? '托管脚本留空;本次结果目录统一归档,.log 文件的新增内容进入实时日志。' : '登记该来源 logs/ 或 results/ 内已存在的文件或目录,每行一项。.log 新增内容进入实时日志,其他结果结束后归档。'),
field('超时时间(秒)', timeout, `填写 5–${limit} 秒之间的整数。${native ? '原环境方案最长支持 72 小时。' : ''}`), el('label', {class: 'checkbox'}, enabled, '启用此脚本'),
native ? note('复用原账号、解释器、目录与已有配置,不修改原项目文件。实际运行按原脚本逻辑产生输出与影响。')
: note(`${profileLabel(script)}。提供 Python 标准库、Bash 和 curl;额外依赖不会自动安装。`),
!native && script.config.profile === 'legacy_smoke' ? note('兼容配置由服务端管理,只读。代理请求有固定上限,不能在此调整服务或模型。', 'info') : null,
feedback, el('div', {class: 'form-footer'}, submit));
form.addEventListener('input', () => {
onDirty();
feedback.replaceChildren(note('配置有未保存的修改。保存后才能开始运行。'));
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled || !form.reportValidity()) return;
feedback.replaceChildren();
let payload;
try {
const seconds = Number(timeout.value);
if (!Number.isSafeInteger(seconds) || seconds < 5 || seconds > limit) throw new Error(`超时时间必须是 5–${limit} 秒之间的整数。`);
if (!name.value.trim()) throw new Error('请填写脚本名称。');
payload = {name: name.value.trim(), description: description.value, enabled: enabled.checked,
config: native ? {native_options: parameters.read(), timeout_seconds: seconds}
: {args: parseArgs(args.value), cwd: cwd.value.trim(), output_paths: parseLines(outputs.value), timeout_seconds: seconds}};
} catch (error) { feedback.replaceChildren(errorBox(error)); return; }
submit.disabled = true;
submit.textContent = '正在保存…';
try {
await api(scriptUrl(script.id), {method: 'PATCH', body: payload}, ctx.signal);
if (ctx.alive) { toast('配置已保存,后续运行将使用新配置。'); await scriptDetail(ctx, script.id); }
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
finally { submit.disabled = false; submit.textContent = '保存配置'; }
});
return form;
}
+176
View File
@@ -0,0 +1,176 @@
@charset "UTF-8";
:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#22332c;background:#f4f6f3;font-synthesis:none;--ink:#22332c;--muted:#748079;--line:#e3e8e1;--green:#237349;--light:#ecf4ec;--danger:#b43d35;--radius:12px}
*{box-sizing:border-box}
body{margin:0;font-size:14px;line-height:1.55}
button,input,textarea,select{font:inherit}
button,a,input,textarea,select{touch-action:manipulation}
a{color:var(--green);text-decoration:none}
a:hover{text-decoration:underline}
button{cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
:focus-visible{outline:3px solid #73b48a;outline-offset:3px}
[hidden]{display:none!important}
h1,h2,h3,p{margin:0}
h1{font-size:28px;letter-spacing:-.8px;line-height:1.25;font-weight:650;overflow-wrap:anywhere}
h2{font-size:17px;font-weight:650}
h3{font-size:15px;font-weight:600}
.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#14231e;color:#b4c0b8;padding:31px 20px;display:flex;flex-direction:column;z-index:5}
.brand{display:flex;align-items:center;gap:12px;color:#f6faf4;padding:0 12px;text-decoration:none!important;margin-bottom:60px}
.brand-mark{display:grid;place-items:center;background:#b9db91;color:#173522;width:37px;height:39px;border-radius:10px;font-size:25px;font-weight:800;transform:rotate(-5deg)}
.brand strong{font-size:25px;letter-spacing:3px;line-height:1;display:block}
.brand small{font-size:9px;letter-spacing:1.6px;color:#a2b5a7;display:block;margin-top:5px}
.sidebar-label{font-size:11px;padding:0 15px;margin-bottom:12px;color:#7e9487;letter-spacing:2px}
nav{display:grid;gap:8px}
nav a{border:1px solid transparent;display:flex;align-items:center;gap:13px;color:#bac8bf;padding:12px 14px;border-radius:8px;font-weight:550}
nav a:hover{background:#1e342a;text-decoration:none}
nav a[aria-current=page]{background:#2b4334;color:#d4edb1;border-color:#3b5642}
nav a>span:first-child{font-size:20px;line-height:1;opacity:.85}
.nav-count{font-size:11px;border-radius:4px;background:#3e5844;min-width:24px;padding:0 5px;text-align:center;margin-left:auto}
.sidebar-note{font-size:11px;border-top:1px solid #304034;padding:21px 12px 0;margin-top:auto;color:#becdbf}
.sidebar-note p{font-size:11px;color:#7d9383;margin-top:9px;line-height:1.9}
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#adc888;margin-right:7px;vertical-align:2px}
.sidebar-bottom{padding:29px 12px 0;color:#879e8d;font-size:12px}
.sidebar-bottom span{font-size:10px;margin-left:7px;color:#617a69}
.workspace{margin-left:220px;min-height:100vh;display:flex;flex-direction:column}
.topbar{height:73px;border-bottom:1px solid var(--line);background:#fbfcf9;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px}
.topbar>div:first-child{display:flex;align-items:center;gap:14px}
.eyebrow{font-size:10px;letter-spacing:1.5px;color:#98a298}
#worker-status{display:flex;align-items:center;gap:8px;color:#748477}
.worker-light{width:7px;height:7px;display:inline-block;border-radius:100%;background:#2e955d}
.worker-light.offline{background:#cd8b36}
.summary{padding:26px 38px 0;display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.stat{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 20px;position:relative}
.stat-label{font-size:12px;color:var(--muted)}
.stat-value{font-size:27px;font-weight:620;letter-spacing:-1px;line-height:1.2;margin-top:6px;display:block}
.stat-foot{font-size:10px;color:#859286;position:absolute;bottom:19px;right:18px}
.stat.accent{background:#edf4e7;border-color:#dce9d3}
.stat.accent .stat-value{color:#477129}
main{padding:30px 38px 36px;flex:1;min-width:0}
.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:26px}
.page-heading p{color:var(--muted);font-size:12px;margin-top:9px;max-width:650px}
.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
.button{border:1px solid #dbe3d8;background:#fff;color:var(--ink);border-radius:7px;padding:9px 15px;min-height:38px;display:inline-flex;justify-content:center;align-items:center;gap:7px;white-space:nowrap;font-size:12px;font-weight:550;text-decoration:none!important;line-height:1.4}
.button:hover:enabled{background:#f1f5ed;border-color:#b8cbb8}
.button.primary{background:#2b703d;border-color:#2b703d;color:white;box-shadow:0 2px 3px #234d2710}
.button.primary:hover:enabled{background:#235e32}
.button.danger{color:#a64236;border-color:#e8c7bf}
.button.small{padding:5px 10px;min-height:29px;font-size:11px}
.button.ghost{border-color:transparent;background:transparent;color:#618063}
.button.detail-link{min-height:46px;min-width:112px;padding:11px 18px;font-size:14px;font-weight:600;color:var(--green);border-color:#bed5c4;background:#f2f8f2}
.button.detail-link:hover,.button.back-button:hover{background:#e6f1e7;border-color:#79a586}
.button.back-button{min-height:44px;padding:10px 17px;font-size:14px;border-color:#b8cdbb;color:var(--green);box-shadow:0 2px 8px #1839230c}
.detail-navigation{position:sticky;top:12px;z-index:3;display:flex;width:fit-content;margin-bottom:20px}
.filters{display:flex;gap:11px;align-items:center;flex-wrap:wrap;background:#fff;border:1px solid var(--line);padding:15px 17px;border-radius:var(--radius);margin-bottom:22px}
.input{border:1px solid #dce3d9;border-radius:7px;padding:9px 11px;background:#fff;color:var(--ink);min-height:38px;min-width:0;width:100%;font-size:12px;transition:border-color .15s}
.input:hover{border-color:#acbfaa}
.input:focus{border-color:#55905e}
.input::placeholder{color:#9aa69a}
textarea.input{resize:vertical;line-height:1.7}
.search{flex:1;min-width:200px;position:relative}
.search .input{padding-left:34px;background:#f8faf6}
.search:before{content:'⌕';position:absolute;left:13px;top:6px;font-size:20px;color:#8b9c89}
.filters select{width:auto;max-width:220px}
.section-title{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:23px 0 13px;font-size:12px}
.section-title h2{font-size:14px;overflow-wrap:anywhere}
.section-title .count{font-size:11px;font-weight:400;color:#8c9b8b;margin-left:9px}
.muted{color:var(--muted);font-size:12px}
.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(275px,1fr));gap:16px}
.script-card{min-width:0;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:19px 20px 16px;display:flex;flex-direction:column;transition:box-shadow .15s,border-color .15s;text-decoration:none!important;color:inherit}
.script-card:hover{border-color:#b5caae;box-shadow:0 5px 18px #233c2910}
.card-top{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:16px}
.language{font-family:Consolas,monospace;font-size:11px;font-weight:bold;padding:5px 8px;border-radius:6px;color:#557b34;background:#f0f5e6;letter-spacing:.4px}
.language.shell{background:#eef1f7;color:#607087}
.script-card h3{color:#263c2b;font-size:15px;overflow-wrap:anywhere}
.script-description{color:#7a877b;line-height:1.8;font-size:12px;margin:9px 0 14px;min-height:43px;overflow-wrap:anywhere}
.card-source{color:#93a090;font-family:Consolas,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:14px}
.card-bottom{border-top:1px solid #ecf0e8;padding-top:12px;display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between;align-items:center;margin-top:auto;font-size:10px;color:#879481}
.card-bottom .detail-link{width:100%}.script-card:hover .detail-link{background:#e6f1e7;border-color:#79a586}
.card-reason{color:#9c7737;font-size:11px;line-height:1.7;margin:-1px 0 14px}
.badge{font-size:10px;white-space:nowrap;display:inline-flex;align-items:center;border-radius:5px;padding:3px 7px;line-height:1.5;background:#eef0ed;color:#69786a;font-weight:500}
.badge:before{content:'';width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px}
.badge.ready,.badge.succeeded{color:#458344;background:#edf6e9}
.badge.running{color:#3472a3;background:#eaf3fa}
.badge.queued,.badge.needs_configuration,.badge.timed_out{color:#a17a26;background:#fcf4df}
.badge.failed,.badge.missing,.badge.interrupted{color:#b55248;background:#fbeeea}
.badge.synthetic{color:#7f648e;background:#f2edf7}
.badge.disabled,.badge.cancelled{color:#7b867a;background:#f0f2ee}
.panel{border:1px solid var(--line);border-radius:var(--radius);background:white;padding:22px;margin-bottom:20px;min-width:0}
.panel-header{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}
.detail-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(310px,1fr);gap:22px;align-items:start}
.detail-grid>.panel{margin-bottom:0}
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:11px;color:#8d9c89;margin:-4px 0 17px;overflow-wrap:anywhere}
.metadata{display:grid;grid-template-columns:90px minmax(0,1fr);column-gap:16px;row-gap:10px;font-size:11px;margin:0}
.metadata dt{color:#8c9a87}
.metadata dd{margin:0;overflow-wrap:anywhere;color:#475840}
.metadata dd.mono,.mono{font-family:"Cascadia Code",Consolas,monospace}
.source-header{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:18px 0 11px}
.source-header select{width:auto;min-width:160px;flex:1}
.source-info{font-size:10px;color:#82917d;overflow-wrap:anywhere;line-height:1.9;margin:12px 0}
pre{white-space:pre;tab-size:4;overflow:auto;margin:0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.8}
.code{background:#f6f8f3;border:1px solid #e4eadf;border-radius:8px;min-height:240px;max-height:580px;padding:18px;color:#425637}
.code[tabindex]:focus{outline-offset:2px}
.field{display:flex;flex-direction:column;gap:7px;margin-bottom:17px}
.field-label{font-size:12px;font-weight:550}
.field small{color:#8b9885;font-size:10px;line-height:1.75}
.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.checkbox{display:flex;gap:9px;align-items:center;margin:18px 0;font-size:12px}
.checkbox input{accent-color:#477e38;width:15px;height:15px;min-height:auto}
.form-footer{border-top:1px solid #e9eee5;padding-top:17px;display:flex;align-items:center;justify-content:flex-end;gap:10px}
.notice{padding:12px 14px;border-radius:8px;border:1px solid #e5e8d4;background:#f7f8ed;color:#7c8055;font-size:12px;margin-bottom:17px;line-height:1.8;display:flex;align-items:center;gap:13px;justify-content:space-between;overflow-wrap:anywhere}
.notice.danger{background:#fff1ee;border-color:#f0d7d0;color:#a34c3d}
.notice.success{background:#eff7e9;border-color:#dfebd4;color:#56783d}
.notice.info{background:#edf3f6;border-color:#dae5eb;color:#647f8d}
.global-notice{margin:18px 38px 0;padding:12px 15px;border:1px solid #ebdac1;background:#fcf5e7;color:#8c744b;border-radius:8px;font-size:12px}
.loading,.empty{padding:65px 25px;text-align:center;color:#819078;grid-column:1/-1}
.loading:before{content:'';display:inline-block;border:2px solid #d7e4cf;border-top-color:#618d51;border-radius:50%;width:14px;height:14px;margin-right:10px;vertical-align:-3px;animation:spin .9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.empty-icon{font-size:33px;color:#a5b49a;display:block;margin-bottom:8px}
.empty p{font-size:12px;margin-top:8px;color:#9ca795}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:white}
table{border-collapse:collapse;width:100%;text-align:left;white-space:nowrap;font-size:12px}
th{color:#93a087;font-size:10px;font-weight:500;background:#f9fbf6;padding:13px 18px}
td{padding:16px 18px;border-top:1px solid #edf0e8;color:#6c7c60}
td:first-child{color:#354b2c;font-weight:550;max-width:360px;white-space:normal;overflow-wrap:anywhere}
td .badge{margin-right:6px}
tbody tr:hover{background:#fcfdf9}
.run-row{position:relative;cursor:pointer}.run-row:hover,.run-row:focus-within{background:#f1f7ef}.run-row:focus-within{box-shadow:inset 0 0 0 2px #73b48a}
.run-name{font-size:13px;color:#354b2c}.run-open:after{content:'';position:absolute;inset:0}
.run-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:20px}
.run-summary>div{border-right:1px solid var(--line);padding-right:15px}
.run-summary>div:last-child{border-right:0}
.run-summary small{display:block;color:#94a18b;font-size:10px;margin-bottom:7px}
.run-summary strong{font-size:12px;font-weight:550;overflow-wrap:anywhere}
.log-panel{padding:0;overflow:hidden}
.log-toolbar{padding:15px 19px;display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid #e0e6d9}
.log-toolbar h2{font-size:14px}
.log{background:#1b281f;color:#cedcbd;padding:22px;min-height:320px;max-height:590px;font-size:12px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere}
.log-hint{font-size:10px;color:#8b9b80;padding:10px 19px;border-top:1px solid #e8eddf;display:flex;justify-content:space-between;gap:12px}
.log-toolbar label{font-size:11px;display:flex;align-items:center;gap:6px;color:#879779}
.artifact-list{display:grid;gap:9px}
.artifact{display:flex;justify-content:space-between;align-items:center;padding:13px 15px;border:1px solid #e6ecde;border-radius:8px;gap:16px}
.artifact-name{font-size:12px;overflow-wrap:anywhere}
.artifact-name small{display:block;font-size:10px;color:#9aa68f;margin-top:3px}
.disclosure{margin-top:17px}
.disclosure summary{cursor:pointer;font-size:12px;color:#71845f;padding:8px 0}
.disclosure pre{max-height:300px;background:#f5f8ef;border-radius:7px;padding:16px;font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere}
.dialog{border:1px solid #dce5d0;border-radius:14px;padding:25px;width:min(580px,calc(100% - 30px));max-height:90vh;color:var(--ink);box-shadow:0 20px 80px #17250d35}
.dialog::backdrop{background:#15201070;backdrop-filter:blur(2px)}
.related-files{display:grid;gap:12px;margin-top:18px}.related-files .code{max-height:420px}.related-files>.button{justify-self:start}
.native-plan .notice:last-child{margin-bottom:0}.native-description{font-size:12px;color:#748079;margin-top:17px;line-height:1.8}
.native-impact{margin:18px 0;padding:14px 16px;background:#f5f8f0;border:1px solid #e4eada;border-radius:8px}.native-impact.attention{background:#fdf6e7;border-color:#ecdcba}
.native-impact ul{padding-left:20px;margin:9px 0;font-size:12px;line-height:1.9}.native-impact h3{font-size:13px}.native-impact .muted{font-size:11px}
.native-command{margin:12px 0;padding:15px;background:#f2f6ee;border:1px solid #dfe7d7;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:280px;font-size:11px}
.native-subtitle{margin-top:20px;font-size:12px}.native-dialog{width:min(720px,calc(100% - 30px))}
.preflight-checks{padding:0;margin:15px 0;list-style:none;display:grid;gap:10px}.preflight-checks li{display:flex;align-items:flex-start;gap:10px;font-size:12px}.preflight-checks p{font-size:11px;color:#7f8c74;margin-top:3px;overflow-wrap:anywhere}
.native-menu{border-top:1px solid var(--line);margin-top:20px;padding-top:20px}.native-menu h3{margin-bottom:12px}.native-parameters{border-top:1px solid var(--line);padding-top:17px}.input[readonly]{background:#f6f8f3;color:#73816a}
.dialog-header{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:23px}
.dialog .code{max-height:55vh;white-space:pre-wrap;overflow-wrap:anywhere}
.page-footer{display:flex;justify-content:space-between;gap:20px;padding:17px 38px;border-top:1px solid var(--line);color:#a0ac94;font-size:10px}
.toast{position:fixed;bottom:26px;left:calc(50% + 110px);transform:translateX(-50%);z-index:20;background:#263e21;color:#e0edcf;padding:13px 22px;border-radius:9px;font-size:12px;box-shadow:0 5px 25px #19311425;max-width:calc(100% - 40px)}
.skip-link{position:fixed;top:-100px;left:15px;padding:10px;background:white;z-index:100}
.skip-link:focus{top:10px}
.noscript{position:fixed;top:80px;left:240px;background:#fff3df;padding:20px}
@media(min-width:1600px){main,.summary{padding-left:55px;padding-right:55px}.catalog-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:1100px){.sidebar{width:184px;padding:29px 14px}.workspace{margin-left:184px}.topbar{padding:0 25px}main{padding:26px 25px}.summary{padding:22px 25px 0;gap:10px}.stat{padding:14px}.stat-foot{display:none}.detail-grid{grid-template-columns:1fr}.toast{left:calc(50% + 92px)}.global-notice{margin-left:25px;margin-right:25px}}
@media(max-width:720px){.sidebar{position:static;width:auto;padding:16px 19px;flex-direction:row;align-items:center;gap:20px}.brand{padding:0;margin:0;gap:8px}.brand strong{font-size:20px}.brand small{font-size:7px}.brand-mark{width:30px;height:33px;font-size:21px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}nav{display:flex;gap:5px;margin-left:auto}nav a{padding:8px 10px;font-size:12px;gap:6px}nav a>span:first-child,.nav-count{display:none}.workspace{margin:0}.topbar{height:48px;padding:0 20px;font-size:10px}.eyebrow{display:none}.summary{padding:19px 18px 0;grid-template-columns:repeat(2,1fr);gap:9px}.stat{padding:12px 15px}.stat-label{font-size:10px}.stat-value{font-size:23px}.stat-foot{display:block;font-size:9px;right:12px;bottom:15px}main{padding:24px 18px}.page-heading{align-items:flex-start;flex-direction:column;gap:16px;margin-bottom:20px}.page-heading h1{font-size:24px}.page-heading p{font-size:11px}.page-heading>.actions{width:100%}.filters{gap:8px;padding:12px}.search{flex-basis:100%}.filters select{flex:1;max-width:none;width:calc(50% - 8px)}.catalog-grid{grid-template-columns:1fr}.panel{padding:17px}.log-panel{padding:0}.run-summary{grid-template-columns:1fr 1fr;row-gap:20px}.run-summary>div:nth-child(2){border:0}.log-toolbar{align-items:flex-start;flex-direction:column;gap:10px}.log-toolbar .actions{width:100%;justify-content:space-between}.log{padding:17px;font-size:11px}.log-hint{flex-direction:column;gap:3px}.artifact{align-items:flex-start;flex-direction:column}.artifact>.actions{align-self:flex-end}.field-grid{grid-template-columns:1fr;gap:0}.page-footer{padding:15px 18px;font-size:9px;flex-direction:column;gap:4px}.toast{left:50%;width:max-content}.global-notice{margin:15px 18px 0}.noscript{left:20px;right:20px}.metadata{grid-template-columns:75px minmax(0,1fr);gap:9px}.source-header{align-items:stretch}}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+125
View File
@@ -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;
}