Files
update-system/client/admin.html
T
2026-07-01 03:32:41 +00:00

623 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Marsco 发布控制台</title>
<style>
body { font-family: Arial, Helvetica, sans-serif; margin: 20px; background: #f4f6fb; color: #222; }
h1, h2 { margin: 0 0 12px; }
.section { background: #fff; border: 1px solid #d8dbe2; border-radius: 10px; padding: 16px; margin-bottom: 18px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.row label { min-width: 110px; margin: 0; }
.row input, .row select, .row button { padding: 8px 10px; font-size: 14px; }
.row input, .row select { min-width: 240px; border: 1px solid #c9cdd5; border-radius: 6px; }
.row button { border: 1px solid #5a8efc; background: #5a8efc; color: #fff; border-radius: 6px; cursor: pointer; }
.row button:hover { background: #4b7ce6; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #e1e4ea; padding: 10px; text-align: left; vertical-align: middle; }
th { background: #f1f5fb; }
td button { margin-right: 6px; }
pre { background: #0f172a; color: #f8fafc; border-radius: 10px; padding: 14px; min-height: 120px; overflow-x: auto; }
.small { font-size: 13px; color: #6b7280; }
.wide { width: 100%; }
:root { --ink:#172033; --muted:#667085; --line:#e4e9f1; --paper:#fff; --brand:#3157d5; --brand-dark:#2447bc; --soft:#f4f6fa; --danger:#d92d20; --success:#067647; }
* { box-sizing:border-box; }
body { max-width:1180px; margin:0 auto 60px; padding:0 24px; font-family:Inter,"Microsoft YaHei",system-ui,-apple-system,sans-serif; color:var(--ink); background:var(--soft); font-size:14px; }
body::before { content:""; position:absolute; inset:0 0 auto; height:190px; z-index:-1; background:linear-gradient(120deg,#121a32,#294681); }
h1 { margin:0 -24px 28px; padding:32px 24px 55px; color:#fff; font-size:30px; letter-spacing:-.03em; }
h1::before { content:"MARSCO UPDATE SERVICE"; display:block; margin-bottom:7px; color:#adc0ff; font-size:11px; letter-spacing:.16em; }
h1::after { content:" · 软件发布与升级管理"; color:#d7e0ff; font-size:14px; font-weight:400; letter-spacing:0; }
h2 { margin:0; font-size:17px; }
.section { border:1px solid var(--line); border-radius:16px; padding:22px; margin-bottom:18px; box-shadow:0 8px 28px rgba(24,36,61,.06); }
.section h2 { padding-bottom:14px; margin-bottom:17px; border-bottom:1px solid #edf0f4; }
.row { gap:12px; margin-top:12px; align-items:flex-end; }
.row label { min-width:auto; color:#344054; font-size:13px; font-weight:650; }
.row input,.row select { min-width:220px; padding:10px 12px; border:1px solid #d0d5dd; border-radius:9px; outline:none; transition:.18s ease; }
.row input:focus,.row select:focus { border-color:#7893e8; box-shadow:0 0 0 3px rgba(49,87,213,.12); }
.row button,td button { padding:9px 13px; border:1px solid #d0d5dd; border-radius:9px; background:#fff; color:#344054; font-weight:650; cursor:pointer; transition:.16s ease; }
.row button:hover,td button:hover { transform:translateY(-1px); border-color:#98a2b3; box-shadow:0 3px 9px rgba(16,24,40,.08); }
#saveTokenBtn,#addAppBtn,#publishBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
#saveTokenBtn:hover,#addAppBtn:hover,#publishBtn:hover { background:var(--brand-dark); }
button:disabled { opacity:.55; cursor:not-allowed; transform:none!important; }
input[type=file] { padding:8px; background:#fafbfc; }
input[type=file]::file-selector-button { border:0; border-radius:7px; padding:7px 11px; margin-right:10px; color:#344054; background:#e9edf5; cursor:pointer; }
table { margin-top:14px; border-collapse:separate; border-spacing:0; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
th,td { padding:12px 14px; border:0; border-bottom:1px solid #edf0f4; }
th { color:#475467; background:#f8f9fb; font-size:12px; letter-spacing:.02em; }
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:#fafbff; }
td button[data-action=delete] { color:var(--danger); border-color:#fecdca; background:#fff7f6; }
.badge { display:inline-flex; border-radius:999px; padding:4px 8px; font-size:12px; font-weight:700; color:#475467; background:#f2f4f7; }
.badge.success { color:var(--success); background:#ecfdf3; }
.badge.fail { color:var(--danger); background:#fef3f2; }
.empty { padding:30px 12px!important; color:var(--muted); text-align:center; }
pre { margin:0; min-height:105px; max-height:320px; padding:15px; color:#d8e4ff; background:#11182a; border-radius:11px; font:12px/1.65 "Cascadia Code",Consolas,monospace; white-space:pre-wrap; }
.small { margin-top:10px; color:var(--muted); }
.token-change { margin-top:16px; padding:14px; border:1px solid #dce3f1; border-radius:11px; background:#f8faff; }
.token-change[hidden] { display:none; }
#toggleTokenBtn { min-width:64px; }
#changeTokenBtn { color:var(--brand); border-color:#b9c7ef; background:#f7f9ff; }
#confirmChangeTokenBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
.toast { position:fixed; right:22px; bottom:22px; z-index:20; max-width:380px; padding:12px 15px; color:#fff; background:#1d2939; border-radius:10px; box-shadow:0 12px 32px rgba(16,24,40,.25); opacity:0; transform:translateY(12px); pointer-events:none; transition:.22s ease; }
.toast.show { opacity:1; transform:translateY(0); }
.toast.error { background:#b42318; }
@media(max-width:760px){body{padding:0 12px}h1{margin:0 -12px 20px;padding:24px 12px 46px;font-size:24px}.section{padding:17px}.row{align-items:stretch;flex-direction:column}.row input,.row select,.row button{width:100%;min-width:0}table{display:block;overflow-x:auto;white-space:nowrap}}
</style>
</head>
<body><div id="toast" class="toast"></div>
<h1>软件发布控制台</h1>
<div class="section">
<h2>管理员令牌</h2>
<div class="row">
<label for="adminToken">X-Admin-Token</label>
<input id="adminToken" type="password" class="wide" autocomplete="off" placeholder="请输入管理员令牌" />
<button id="toggleTokenBtn" type="button" aria-label="显示令牌">显示</button>
<button id="saveTokenBtn" type="button">登录并保存</button>
<button id="changeTokenBtn" type="button">更改令牌</button>
</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希。</div>
<div id="tokenChangePanel" class="token-change" hidden>
<div class="row"><label for="newAdminToken">新令牌</label><input id="newAdminToken" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /><label for="confirmAdminToken">确认新令牌</label><input id="confirmAdminToken" type="password" autocomplete="new-password" placeholder="再次输入新令牌" /><button id="confirmChangeTokenBtn" type="button">确认更改</button><button id="cancelChangeTokenBtn" type="button">取消</button></div>
</div>
</div>
<div class="section">
<h2>应用管理</h2>
<div class="row">
<button id="refreshAppsBtn" type="button">刷新应用列表</button>
<label for="appSelect">选择应用</label>
<select id="appSelect"></select>
</div>
<div class="row">
<label for="newAppId">App ID</label>
<input id="newAppId" type="text" placeholder="例如 myapp" />
<label for="newAppName">App 名称</label>
<input id="newAppName" type="text" placeholder="例如 我的应用" />
<button id="addAppBtn" type="button">新增应用</button>
</div>
</div>
<div class="section">
<h2>发布新版本</h2>
<div class="row">
<label for="publishAppId">App ID</label>
<input id="publishAppId" type="text" placeholder="请选择应用或手动输入" />
<label for="publishVersion">版本号</label>
<input id="publishVersion" type="text" value="1.0.0" />
<label for="publishChannel">渠道</label>
<select id="publishChannel"><option value="stable">stable(正式版)</option><option value="preview">preview(预览版)</option><option value="dev">dev(开发版)</option></select>
</div>
<div class="row">
<label for="publishFiles">软件根目录</label>
<input id="publishFiles" type="file" webkitdirectory directory multiple />
<span id="publishFileSummary" class="small">请选择包含 MainApp.exe 的发布目录</span>
<button id="publishBtn" type="button">发布版本</button>
</div>
<pre id="publishFilePreview">尚未选择发布目录</pre>
</div>
<div class="section">
<h2>版本列表</h2>
<div class="row">
<button id="refreshVersionsBtn" type="button">刷新版本列表</button>
<span class="small">当前应用:<strong id="currentAppLabel">未选择</strong></span>
<span class="small">将历史版本设为最新时,仅在该渠道策略勾选“允许降级”后客户端才会回退。</span>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>版本</th>
<th>渠道</th>
<th>最新</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="versionRows"></tbody>
</table>
</div>
<div class="section">
<h2>版本运行策略</h2>
<div class="row">
<label for="policyChannel">渠道</label><select id="policyChannel"><option>stable</option><option>preview</option><option>dev</option></select>
<label><input id="policyForceUpdate" type="checkbox" /> 强制升级</label>
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
</div>
<div class="row">
<label for="policyMinVersion">最低支持版本</label><input id="policyMinVersion" placeholder="留空表示不限制" />
<label for="policyValidUntil">离线有效期</label><input id="policyValidUntil" value="2099-12-31T23:59:59Z" />
<label for="policyDisabledVersions">禁用版本</label><input id="policyDisabledVersions" placeholder="多个版本用逗号分隔" />
</div>
<div class="row">
<label for="policyMessage">提示信息</label><input id="policyMessage" class="wide" placeholder="客户端显示的策略提示" />
<button id="loadPolicyBtn" type="button">读取策略</button><button id="savePolicyBtn" type="button">保存并递增序列</button>
<span class="small">当前 policy_seq<strong id="policySeq">-</strong></span>
</div>
</div>
<div class="section">
<h2>升级日志</h2>
<div class="row">
<button id="refreshReportsBtn" type="button">刷新日志</button>
</div>
<table>
<thead>
<tr>
<th>设备</th>
<th>旧版本</th>
<th>新版本</th>
<th>结果</th>
<th>时间</th>
</tr>
</thead>
<tbody id="reportRows"></tbody>
</table>
</div>
<div class="section">
<h2>调试输出</h2>
<pre id="output">准备就绪...</pre>
</div>
<script>
const apiBase = 'http://' + (location.hostname || '127.0.0.1') + ':8000';
const adminTokenInput = document.getElementById('adminToken');
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
const tokenChangePanel = document.getElementById('tokenChangePanel');
const newAdminTokenInput = document.getElementById('newAdminToken');
const confirmAdminTokenInput = document.getElementById('confirmAdminToken');
const out = document.getElementById('output');
const toast = document.getElementById('toast');
const appSelect = document.getElementById('appSelect');
const currentAppLabel = document.getElementById('currentAppLabel');
const publishAppIdInput = document.getElementById('publishAppId');
const publishVersionInput = document.getElementById('publishVersion');
const publishChannelInput = document.getElementById('publishChannel');
const publishFilesInput = document.getElementById('publishFiles');
const publishFileSummary = document.getElementById('publishFileSummary');
const publishFilePreview = document.getElementById('publishFilePreview');
const publishBtn = document.getElementById('publishBtn');
const versionRows = document.getElementById('versionRows');
const reportRows = document.getElementById('reportRows');
let currentToken = localStorage.getItem('admin_token') || '';
let apps = [];
let currentAppId = '';
function log(message) {
if (typeof message === 'string') {
out.textContent = message;
return;
}
if (message instanceof Error) {
out.textContent = `${message.name}: ${message.message}`;
return;
}
out.textContent = JSON.stringify(message, null, 2);
}
let toastTimer;
function notify(message, isError = false) {
toast.textContent = message;
toast.className = `toast show${isError ? " error" : ""}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = "toast"; }, 2800);
}
function getHeaders(contentType) {
const headers = { 'X-Admin-Token': currentToken };
if (contentType) headers['Content-Type'] = contentType;
return headers;
}
async function request(path, options = {}) {
const url = apiBase + path;
const reqOptions = {
...options,
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
};
delete reqOptions.contentType;
if (!reqOptions.body) delete reqOptions.body;
const resp = await fetch(url, reqOptions);
const text = await resp.text();
let body;
try { body = JSON.parse(text); } catch { body = text; }
if (!resp.ok) {
const error = new Error(`HTTP ${resp.status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
error.status = resp.status;
error.body = body;
throw error;
}
return body;
}
function updateTokenInput() {
adminTokenInput.value = currentToken;
}
function updateAppSelection() {
appSelect.innerHTML = '<option value="">请选择应用</option>';
apps.forEach(app => {
const option = document.createElement('option');
option.value = app.app_id;
option.textContent = `${app.app_id} (${app.app_name})`;
appSelect.appendChild(option);
});
if (currentAppId) {
appSelect.value = currentAppId;
publishAppIdInput.value = currentAppId;
currentAppLabel.textContent = currentAppId;
} else {
currentAppLabel.textContent = '未选择';
}
}
async function loadApps() {
log('正在加载应用列表...');
try {
const data = await request('/admin/app/list');
apps = data.list || [];
updateAppSelection();
log({ apps });
} catch (err) {
log({ error: '加载应用列表失败', err });
}
}
async function addApp() {
const appId = document.getElementById('newAppId').value.trim();
const appName = document.getElementById('newAppName').value.trim();
if (!appId || !appName) {
log('请输入 App ID 和 App 名称');
return;
}
log('正在新增应用...');
try {
const body = await request('/admin/app/add', {
method: 'POST',
contentType: 'application/json',
body: JSON.stringify({ app_id: appId, app_name: appName }),
});
log(body);
document.getElementById('newAppId').value = '';
document.getElementById('newAppName').value = '';
await loadApps();
} catch (err) {
log({ error: '新增应用失败', err });
}
}
function relativePathForFile(file) {
const raw = (file.webkitRelativePath || file.name).replaceAll(chrBackslash, '/');
const parts = raw.split('/').filter(Boolean);
if (file.webkitRelativePath && parts.length > 1) parts.shift();
return parts.join('/');
}
const chrBackslash = String.fromCharCode(92);
function selectedUploadEntries() {
const excludedFiles = new Set(['client.ini', 'bootstrap.exe', 'config/app_config.json', 'config/local_state.json', 'config/client_identity.dat', 'config/version_policy.dat']);
return Array.from(publishFilesInput.files).map(file => ({ file, path: relativePathForFile(file) })).filter(item => {
const path = item.path.toLowerCase();
return !excludedFiles.has(path) && !path.startsWith('update/') && !path.startsWith('update_temp/') && !path.endsWith('.pdb') && !path.endsWith('.ilk');
});
}
function validateReleaseRoot(entries) {
if (!entries.length) return '请选择软件发布根目录';
const lowerPaths = entries.map(item => item.path.toLowerCase());
if (!lowerPaths.includes('mainapp.exe')) {
const nestedMain = lowerPaths.find(path => path.endsWith('/mainapp.exe'));
if (nestedMain) {
const expectedRoot = nestedMain.slice(0, -'/mainapp.exe'.length);
return '选择层级过高:MainApp.exe 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
}
return '所选目录根级没有 MainApp.exe,不能作为软件发布根目录';
}
return '';
}
function renderSelectedDirectory() {
const allCount = publishFilesInput.files.length;
const entries = selectedUploadEntries();
const totalBytes = entries.reduce((sum, item) => sum + item.file.size, 0);
const skipped = allCount - entries.length;
const validationError = validateReleaseRoot(entries);
publishBtn.disabled = Boolean(validationError);
publishFileSummary.textContent = validationError || (allCount ? ('将发布 ' + entries.length + ' 个文件,共 ' + (totalBytes / 1024 / 1024).toFixed(2) + ' MB;排除 ' + skipped + ' 个运行时/调试文件') : '请选择包含 MainApp.exe 的发布目录');
publishFileSummary.style.color = validationError ? '#d92d20' : '';
const paths = entries.slice(0, 30).map(item => item.path);
publishFilePreview.textContent = paths.length ? paths.join('\n') + (entries.length > 30 ? '\n... 其余 ' + (entries.length - 30) + ' 个文件' : '') : '尚未选择发布目录';
}
async function publishVersion() {
const appId = publishAppIdInput.value.trim();
const version = publishVersionInput.value.trim();
const channel = publishChannelInput.value.trim() || 'stable';
const entries = selectedUploadEntries();
if (!appId) { log('请选择或填写 App ID'); return; }
if (!version) { log('请输入版本号'); return; }
const rootError = validateReleaseRoot(entries);
if (rootError) { log(rootError); notify(rootError, true); return; }
const formData = new FormData();
formData.append('app_id', appId);
formData.append('version', version);
formData.append('channel', channel);
for (const item of entries) {
formData.append('files', item.file, item.file.name);
formData.append('relative_paths', item.path);
}
publishBtn.disabled = true;
const originalText = publishBtn.textContent;
publishBtn.textContent = '发布中...';
log('正在发布版本...');
try {
const body = await request('/admin/publish', {
method: 'POST',
headers: { 'X-Admin-Token': currentToken },
body: formData,
});
log({ success: '发布完成', response: body });
await loadVersions();
} catch (err) {
console.error('publishVersion error', err);
if (err instanceof Error) {
log({
error: err.message,
status: err.status || 'unknown',
details: err.body || err,
});
} else {
log({ error: '发布失败', details: err });
}
} finally {
publishBtn.disabled = false;
publishBtn.textContent = originalText;
}
}
async function loadVersions() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) {
log('请先选择应用或填写 App ID');
return;
}
log('正在加载版本列表...');
try {
const data = await request(`/admin/version/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
versionRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">该应用还没有发布版本</td></tr>';
rows.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.id}</td>
<td>${item.version}</td>
<td>${item.channel}</td>
<td><span class="badge ${item.latest ? 'success' : ''}">${item.latest ? '当前最新' : '历史版本'}</span></td>
<td>${item.create_time || ''}</td>
<td>
<button type="button" data-action="set-latest" data-id="${item.id}">设为最新</button>
<button type="button" data-action="delete" data-id="${item.id}">删除</button>
</td>
`;
versionRows.appendChild(tr);
});
log({ versions: rows });
} catch (err) {
log({ error: '载入版本列表失败', err });
}
}
async function setLatest(versionId) {
log(`正在设置版本 ${versionId} 为最新...`);
try {
const body = await request('/admin/version/set-latest', {
method: 'POST',
contentType: 'application/json',
body: JSON.stringify({ version_id: Number(versionId) }),
});
log(body);
await loadVersions();
} catch (err) {
log({ error: '设置最新版本失败', err });
}
}
async function deleteVersion(versionId) {
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
log(`正在删除版本 ${versionId} ...`);
try {
const body = await request('/admin/version/delete', {
method: 'POST',
contentType: 'application/json',
body: JSON.stringify({ version_id: Number(versionId) }),
});
log(body);
await loadVersions();
} catch (err) {
log({ error: '删除版本失败', err });
}
}
async function loadPolicy() {
const appId = currentAppId || publishAppIdInput.value.trim();
const channel = document.getElementById('policyChannel').value;
if (!appId) { notify('请先选择应用', true); return; }
try {
const p = await request(`/admin/policy?app_id=${encodeURIComponent(appId)}&channel=${encodeURIComponent(channel)}`);
document.getElementById('policyForceUpdate').checked = Boolean(p.force_update);
document.getElementById('policyAllowRollback').checked = Boolean(p.allow_rollback);
document.getElementById('policyOfflineAllowed').checked = Boolean(p.offline_allowed);
document.getElementById('policyMinVersion').value = p.min_supported_version || '';
document.getElementById('policyValidUntil').value = p.valid_until || '';
document.getElementById('policyDisabledVersions').value = (p.disabled_versions || []).join(', ');
document.getElementById('policyMessage').value = p.message || '';
document.getElementById('policySeq').textContent = p.policy_seq;
log({ policy: p });
} catch (err) { log(err); notify('读取策略失败', true); }
}
async function savePolicy() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { notify('请先选择应用', true); return; }
const disabled = document.getElementById('policyDisabledVersions').value.split(',').map(v => v.trim()).filter(Boolean);
const body = {
app_id: appId, channel: document.getElementById('policyChannel').value,
force_update: document.getElementById('policyForceUpdate').checked,
allow_rollback: document.getElementById('policyAllowRollback').checked,
offline_allowed: document.getElementById('policyOfflineAllowed').checked,
min_supported_version: document.getElementById('policyMinVersion').value.trim(),
valid_until: document.getElementById('policyValidUntil').value.trim(),
disabled_versions: disabled, message: document.getElementById('policyMessage').value.trim()
};
try {
const result = await request('/admin/policy/save', {method:'POST', contentType:'application/json', body:JSON.stringify(body)});
log(result); notify('策略已保存,新序列为 ' + result.policy_seq); await loadPolicy();
} catch (err) { log(err); notify('保存策略失败', true); }
}
async function loadReports() {
log('正在加载升级日志...');
try {
const data = await request('/admin/report/list');
const rows = data.list || [];
reportRows.innerHTML = rows.length ? '' : '<tr><td colspan="5" class="empty">暂无升级日志</td></tr>';
rows.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.device_id}</td>
<td>${item.from_version}</td>
<td>${item.to_version}</td>
<td><span class="badge ${item.result === 'success' ? 'success' : 'fail'}">${item.result === 'success' ? '成功' : '失败'}</span></td>
<td>${item.create_time || ''}</td>
`;
reportRows.appendChild(tr);
});
log({ reports: rows });
} catch (err) {
log({ error: '加载日志失败', err });
}
}
adminTokenInput.addEventListener('change', () => {
currentToken = adminTokenInput.value.trim() || currentToken;
});
toggleTokenBtn.addEventListener('click', () => {
const showing = adminTokenInput.type === 'text';
adminTokenInput.type = showing ? 'password' : 'text';
toggleTokenBtn.textContent = showing ? '显示' : '隐藏';
toggleTokenBtn.setAttribute('aria-label', showing ? '显示令牌' : '隐藏令牌');
});
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
const token = adminTokenInput.value.trim();
if (!token) { notify('令牌不能为空', true); return; }
currentToken = token;
localStorage.setItem('admin_token', currentToken);
try {
await loadApps();
notify('令牌验证成功并已保存');
} catch (err) {
notify('令牌验证失败', true);
}
});
document.getElementById('changeTokenBtn').addEventListener('click', () => {
tokenChangePanel.hidden = false;
newAdminTokenInput.focus();
});
document.getElementById('cancelChangeTokenBtn').addEventListener('click', () => {
tokenChangePanel.hidden = true;
newAdminTokenInput.value = '';
confirmAdminTokenInput.value = '';
});
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
const nextToken = newAdminTokenInput.value.trim();
const confirmation = confirmAdminTokenInput.value.trim();
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
try {
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
currentToken = nextToken;
adminTokenInput.value = nextToken;
localStorage.setItem('admin_token', nextToken);
tokenChangePanel.hidden = true;
newAdminTokenInput.value = '';
confirmAdminTokenInput.value = '';
log(result);
notify('管理员令牌已更改');
} catch (err) {
log(err);
notify('更改令牌失败,请确认当前令牌正确', true);
}
});
document.getElementById('refreshAppsBtn').addEventListener('click', loadApps);
document.getElementById('addAppBtn').addEventListener('click', addApp);
publishBtn.addEventListener('click', publishVersion);
publishFilesInput.addEventListener('change', renderSelectedDirectory);
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
document.getElementById('refreshReportsBtn').addEventListener('click', loadReports);
document.getElementById('loadPolicyBtn').addEventListener('click', loadPolicy);
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
appSelect.addEventListener('change', (event) => {
currentAppId = event.target.value;
if (currentAppId) {
publishAppIdInput.value = currentAppId;
}
currentAppLabel.textContent = currentAppId || '未选择';
if (currentAppId) loadPolicy();
});
versionRows.addEventListener('click', async (event) => {
const button = event.target.closest('button');
if (!button) return;
const action = button.dataset.action;
const id = button.dataset.id;
if (action === 'set-latest') {
await setLatest(id);
} else if (action === 'delete') {
await deleteVersion(id);
}
});
window.addEventListener('load', async () => {
updateTokenInput();
await loadApps();
await loadReports();
});
</script>
</body>
</html>