Files
update-system/client/admin.html
T

1080 lines
49 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>
:root {
--bg:#f6f7fb;
--surface:#fff;
--ink:#172033;
--muted:#667085;
--line:#dfe5ee;
--field:#fdfefe;
--brand:#3157d5;
--brand-hover:#2447bc;
--danger:#d92d20;
--success:#067647;
--warning:#9a6700;
}
* { box-sizing:border-box; }
html { background:var(--bg); }
body {
max-width:1280px;
margin:0 auto;
padding:0 24px 48px;
color:var(--ink);
background:var(--bg);
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
h1 {
margin:0 -24px 22px;
padding:24px;
color:var(--ink);
background:var(--surface);
border-bottom:1px solid var(--line);
box-shadow:0 1px 2px rgba(16,24,40,.04);
font-size:26px;
line-height:1.2;
letter-spacing:0;
}
h1::before {
content:"MARSCO UPDATE SERVICE";
display:block;
margin-bottom:6px;
color:#5d6b82;
font-size:11px;
font-weight:700;
letter-spacing:.14em;
}
h1::after {
content:" · 软件发布与升级管理";
color:var(--muted);
font-size:14px;
font-weight:400;
}
h2 {
margin:0 0 16px;
padding-bottom:12px;
border-bottom:1px solid #edf0f4;
font-size:16px;
line-height:1.3;
letter-spacing:0;
}
.section {
margin-bottom:16px;
padding:18px;
overflow-x:auto;
background:var(--surface);
border:1px solid var(--line);
border-radius:8px;
box-shadow:0 1px 3px rgba(16,24,40,.06);
}
.row {
display:flex;
flex-wrap:wrap;
align-items:center;
gap:10px 12px;
margin-top:12px;
}
.section h2 + .row { margin-top:0; }
.row label {
flex:0 0 116px;
min-width:0;
margin:0;
color:#344054;
font-size:13px;
font-weight:650;
line-height:1.25;
}
.row label input[type="checkbox"] { margin:0 7px 0 0; }
.row input,
.row select {
flex:1 1 220px;
width:auto;
min-width:180px;
max-width:360px;
height:38px;
padding:8px 11px;
color:var(--ink);
background:var(--field);
border:1px solid #cfd6e2;
border-radius:6px;
outline:none;
transition:border-color .16s ease, box-shadow .16s ease, background .16s ease;
}
.row input[type="number"] { flex-basis:120px; max-width:160px; }
.row input[type="checkbox"] {
flex:0 0 auto;
width:16px;
min-width:16px;
height:16px;
padding:0;
}
.row input[type="file"] {
flex:1 1 420px;
max-width:none;
min-height:38px;
height:auto;
padding:7px;
background:#fafbfc;
}
.row input[type="file"]::file-selector-button {
margin-right:10px;
padding:7px 11px;
color:#344054;
background:#e9edf5;
border:0;
border-radius:6px;
cursor:pointer;
}
.row input:focus,
.row select:focus {
border-color:#7893e8;
background:#fff;
box-shadow:0 0 0 3px rgba(49,87,213,.12);
}
.row .wide,
.row input.wide {
flex:1 1 540px;
max-width:none;
}
.row button,
td button {
min-height:38px;
padding:8px 12px;
color:#344054;
background:#fff;
border:1px solid #cfd6e2;
border-radius:6px;
font-weight:650;
white-space:nowrap;
cursor:pointer;
transition:border-color .16s ease, box-shadow .16s ease, transform .16s ease, background .16s ease;
}
.row button {
flex:0 0 auto;
display:inline-flex;
align-items:center;
justify-content:center;
height:38px;
}
.action-row {
justify-content:flex-start;
padding-left:128px;
}
.checkbox-label {
flex:0 0 auto!important;
display:inline-flex;
align-items:center;
gap:7px;
min-width:96px!important;
color:#344054;
}
.checkbox-label input[type="checkbox"] { margin:0; }
.row button:hover,
td button:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
#saveTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#saveTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover { background:var(--brand-hover); }
#changeTokenBtn,
#confirmChangeTokenBtn {
color:var(--brand);
border-color:#b9c7ef;
background:#f7f9ff;
}
#confirmChangeTokenBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#toggleTokenBtn { min-width:64px; }
button:disabled {
opacity:.55;
cursor:not-allowed;
box-shadow:none!important;
transform:none!important;
}
table {
width:100%;
min-width:720px;
margin-top:14px;
overflow:hidden;
border:1px solid var(--line);
border-radius:8px;
border-collapse:separate;
border-spacing:0;
background:#fff;
}
th,
td {
padding:11px 12px;
border:0;
border-bottom:1px solid #edf0f4;
text-align:left;
vertical-align:middle;
}
th {
color:#475467;
background:#f8f9fb;
font-size:12px;
font-weight:700;
letter-spacing:0;
white-space:nowrap;
}
td { color:#253047; }
td:last-child { white-space:nowrap; }
td button { margin:2px 4px 2px 0; }
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;
align-items:center;
min-height:22px;
padding:3px 8px;
color:#475467;
background:#f2f4f7;
border-radius:999px;
font-size:12px;
font-weight:700;
white-space:nowrap;
}
.badge.success { color:var(--success); background:#ecfdf3; }
.badge.fail { color:var(--danger); background:#fef3f2; }
.empty {
padding:28px 12px!important;
color:var(--muted);
text-align:center;
}
.small {
display:inline-flex;
align-items:center;
min-height:28px;
margin:0;
color:var(--muted);
font-size:13px;
line-height:1.35;
}
pre {
margin:14px 0 0;
min-height:105px;
max-height:320px;
padding:14px;
overflow:auto;
color:#d8e4ff;
background:#11182a;
border-radius:8px;
font:12px/1.65 "Cascadia Code",Consolas,monospace;
white-space:pre-wrap;
}
.token-change {
margin-top:14px;
padding:14px;
background:#f8faff;
border:1px solid #dce3f1;
border-radius:8px;
}
.token-change[hidden] { display:none; }
.toast {
position:fixed;
right:22px;
bottom:22px;
z-index:20;
max-width:380px;
padding:12px 15px;
color:#fff;
background:#1d2939;
border-radius:8px;
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:820px) {
body { padding:0 12px 36px; }
h1 { margin:0 -12px 16px; padding:20px 12px; font-size:23px; }
h1::after { display:block; margin-top:4px; }
.section { padding:15px; margin-bottom:12px; }
.row { display:grid; grid-template-columns:1fr; align-items:stretch; }
.row label,
.row input,
.row select,
.row button,
.row .wide,
.row input.wide {
width:100%;
max-width:none;
min-width:0;
}
.row label { min-height:auto; }
.action-row { padding-left:0; }
.row input[type="checkbox"] { width:16px; min-width:16px; }
table { min-width:760px; }
.toast { right:12px; left:12px; max-width:none; }
}
</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">令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验。</div>
<div class="small">当前连接服务端:<strong id="apiBaseLabel"></strong></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="再次输入新令牌" />
</div>
<div class="row action-row">
<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="channelCode">渠道代码</label><input id="channelCode" placeholder="例如 enterprise">
<label for="channelName">显示名称</label><input id="channelName" placeholder="例如 企业版">
<label for="channelOrder">排序</label><input id="channelOrder" type="number" value="100">
<label class="checkbox-label"><input id="channelEnabled" type="checkbox" checked>启用</label>
</div>
<div class="row action-row"><button id="saveChannelBtn" type="button">新增或保存</button><button id="refreshChannelsBtn" type="button">刷新渠道</button></div>
<table><thead><tr><th>代码</th><th>名称</th><th>状态</th><th>排序</th><th>操作</th></tr></thead><tbody id="channelRows"></tbody></table>
</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"></select>
<label for="publishProtocol">客户端协议</label><input id="publishProtocol" type="number" min="1" value="3" />
</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>
<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"></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>License 授权</h2>
<div class="row">
<label for="licenseCustomer">客户</label><input id="licenseCustomer" placeholder="客户或项目名称">
<label for="licenseChannel">渠道</label><select id="licenseChannel"></select>
<label for="licenseMaxDevices">最大设备数</label><input id="licenseMaxDevices" type="number" min="1" value="1">
<label for="licenseValidUntil">有效期</label><input id="licenseValidUntil" value="2099-12-31T23:59:59Z">
</div>
<div class="row action-row"><button id="createLicenseBtn" type="button">创建授权</button><button id="refreshLicensesBtn" type="button">刷新授权</button></div>
<div class="row"><label for="createdLicenseKey">新授权密钥</label><input id="createdLicenseKey" class="wide" readonly placeholder="仅显示一次;创建后请立即复制到客户端 app_config.json 的 license_key"></div>
<table><thead><tr><th>License ID</th><th>客户</th><th>渠道</th><th>设备</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="licenseRows"></tbody></table>
</div>
<div class="section">
<h2>设备管理</h2>
<div class="row"><button id="refreshDevicesBtn" type="button">刷新设备</button><span class="small">显示当前应用登记的设备;禁用后客户端请求会立即被拒绝</span></div>
<table><thead><tr><th>设备 ID</th><th>状态</th><th>凭证序列</th><th>最近访问</th><th>IP</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table>
</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><div class="row"><button id="refreshDownloadLogsBtn">刷新下载日志</button></div>
<table><thead><tr><th>设备</th><th>License</th><th>版本/渠道</th><th>文件</th><th>大小</th><th>结果</th><th>IP</th><th>时间</th></tr></thead><tbody id="downloadLogRows"></tbody></table>
</div>
<div class="section">
<h2>管理员审计日志</h2><div class="row"><button id="refreshAuditLogsBtn">刷新审计日志</button></div>
<table><thead><tr><th>管理员指纹</th><th>操作</th><th>结果</th><th>状态码</th><th>IP</th><th>时间</th></tr></thead><tbody id="auditLogRows"></tbody></table>
</div>
<div class="section">
<h2>调试输出</h2>
<pre id="output">准备就绪...</pre>
</div>
<script>
const apiBase = (location.protocol === 'http:' || location.protocol === 'https:')
? location.origin
: 'http://127.0.0.1:8000';
const adminTokenInput = document.getElementById('adminToken');
const apiBaseLabel = document.getElementById('apiBaseLabel');
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 publishProtocolInput = document.getElementById('publishProtocol');
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');
const deviceRows = document.getElementById('deviceRows');
const licenseRows = document.getElementById('licenseRows');
const channelRows = document.getElementById('channelRows');
const downloadLogRows = document.getElementById('downloadLogRows');
const auditLogRows = document.getElementById('auditLogRows');
let currentToken = localStorage.getItem('admin_token') || '';
let apps = [];
let currentAppId = '';
let channels = [];
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,
cache: 'no-store',
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);
let releaseMainExecutable = 'MainApp.exe';
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();
const parts = path.split('/');
const blockedDirectory = parts.slice(0, -1).some(part => part === '.git' || part === '.vs' || part === 'cmakefiles' || part === 'debug' || part === 'update' || part === 'update_temp' || part.startsWith('build') || part.endsWith('_autogen'));
return !excludedFiles.has(path) && !blockedDirectory && !path.endsWith('.pdb') && !path.endsWith('.ilk') && !path.endsWith('.obj');
});
}
function validateReleaseRoot(entries) {
if (!entries.length) return '请选择软件发布根目录';
const lowerPaths = entries.map(item => item.path.toLowerCase());
const mainName = releaseMainExecutable.toLowerCase();
if (!lowerPaths.includes(mainName)) {
const nestedMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (nestedMain) {
const expectedRoot = nestedMain.slice(0, -(mainName.length + 1));
return '选择层级过高:' + releaseMainExecutable + ' 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
}
return '所选目录根级没有 ' + releaseMainExecutable + ',不能作为软件发布根目录';
}
const duplicateMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (duplicateMain) return '目录中存在嵌套的重复主程序 ' + duplicateMain + ',请选择干净的 Release 输出目录';
return '';
}
async function loadRuntimeConfig() {
try {
const data = await request('/admin/runtime-config');
releaseMainExecutable = data.release_main_executable || releaseMainExecutable;
} catch (err) {
log({ warning: '读取发布配置失败,将使用默认主程序名', details: err.message || err });
}
}
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) + ' 个文件' : '') : '尚未选择发布目录';
}
function renderChannels() {
const enabled=channels.filter(c=>c.enabled); const selects=[publishChannelInput,document.getElementById('policyChannel'),document.getElementById('licenseChannel')];
selects.forEach(select=>{const previous=select.value;select.innerHTML='';enabled.forEach(c=>{const o=document.createElement('option');o.value=c.channel_code;o.textContent=`${c.channel_code}${c.display_name}`;select.appendChild(o);});if(enabled.some(c=>c.channel_code===previous))select.value=previous;});
channelRows.innerHTML=channels.length?'':'<tr><td colspan="5" class="empty">暂无渠道</td></tr>';channels.forEach(c=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${c.channel_code}</td><td>${c.display_name}</td><td><span class="badge ${c.enabled?'success':'fail'}">${c.enabled?'启用':'停用'}</span></td><td>${c.sort_order}</td><td><button data-channel-code="${c.channel_code}">编辑</button></td>`;channelRows.appendChild(tr);});
}
async function loadChannels(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){channels=[];renderChannels();return;}try{const data=await request(`/admin/channel/list?app_id=${encodeURIComponent(appId)}`);channels=data.list||[];renderChannels();}catch(err){log(err);notify('读取渠道失败',true);}}
async function saveChannel(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;}const body={app_id:appId,channel_code:document.getElementById('channelCode').value.trim(),display_name:document.getElementById('channelName').value.trim(),sort_order:Number(document.getElementById('channelOrder').value),enabled:document.getElementById('channelEnabled').checked};try{await request('/admin/channel/save',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});notify('渠道已保存');await loadChannels();}catch(err){log(err);notify('保存渠道失败',true);}}
async function publishVersion() {
const appId = publishAppIdInput.value.trim();
const version = publishVersionInput.value.trim();
const channel = publishChannelInput.value.trim();
const entries = selectedUploadEntries();
if (!appId) { log('请选择或填写 App ID'); return; }
if (!version) { log('请输入版本号'); return; }
if (!channel) { notify('当前应用没有已启用渠道', true); 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);
formData.append('client_protocol', String(Math.max(1, Number(publishProtocolInput.value) || 3)));
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="7" 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">P${item.client_protocol || 1}</span></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="set-protocol" data-id="${item.id}" data-protocol="${item.client_protocol || 1}">修改协议</button>
<button type="button" data-action="offline-package" data-id="${item.id}" data-version="${item.version}">下载 .upd</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 downloadOfflinePackage(versionId, version) {
notify('正在生成离线包,请保持页面打开');
try { const resp=await fetch(`${apiBase}/admin/version/offline-package`,{method:'POST',headers:getHeaders('application/json'),body:JSON.stringify({version_id:Number(versionId)})}); if(!resp.ok)throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); const blob=await resp.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a');a.href=url;a.download=`${currentAppId}_${version}.upd`;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);notify('离线包已生成'); } catch(err){log(err);notify('离线包生成失败',true);}
}
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 setVersionProtocol(versionId, currentProtocol) {
const raw = window.prompt('请输入该版本实际支持的客户端协议号', String(currentProtocol || 1));
if (raw === null) return;
const protocol = Number(raw);
if (!Number.isInteger(protocol) || protocol < 1) { notify('协议号必须是正整数', true); return; }
try {
const body = await request('/admin/version/set-protocol', {method:'POST', contentType:'application/json',
body:JSON.stringify({version_id:Number(versionId), client_protocol:protocol})});
log(body); notify('版本协议已更新为 P' + protocol); await loadVersions();
} catch (err) { log(err); notify('修改协议失败', true); }
}
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 loadLicenses() {
const appId=currentAppId||publishAppIdInput.value.trim(); if(!appId){licenseRows.innerHTML='<tr><td colspan="7" class="empty">请先选择应用</td></tr>';return;}
try { const data=await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`); const rows=data.list||[]; licenseRows.innerHTML=rows.length?'':'<tr><td colspan="7" class="empty">暂无授权</td></tr>'; rows.forEach(item=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${item.license_id}</td><td>${item.customer_name}</td><td>${item.channel_code}</td><td>${item.used_devices} / ${item.max_devices}</td><td>${item.valid_until}</td><td><span class="badge ${item.status==='active'?'success':'fail'}">${item.status==='active'?'有效':'已禁用'}</span></td><td><button data-license-id="${item.license_id}" data-status="${item.status}">${item.status==='active'?'禁用':'启用'}</button></td>`;licenseRows.appendChild(tr);}); } catch(err){log(err);notify('读取授权失败',true);}
}
async function createLicense(){
const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;} const body={app_id:appId,customer_name:document.getElementById('licenseCustomer').value.trim(),channel:document.getElementById('licenseChannel').value,max_devices:Number(document.getElementById('licenseMaxDevices').value),valid_until:document.getElementById('licenseValidUntil').value.trim()};
try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;log(result);notify('授权已创建,请立即复制密钥');await loadLicenses();}catch(err){log(err);notify('创建授权失败',true);}
}
async function toggleLicense(id,status){try{await request('/admin/license/set-status',{method:'POST',contentType:'application/json',body:JSON.stringify({license_id:id,status:status==='active'?'disabled':'active'})});await loadLicenses();notify('授权状态已更新');}catch(err){log(err);notify('修改授权失败',true);}}
async function loadDevices() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { deviceRows.innerHTML = '<tr><td colspan="6" class="empty">请先选择应用</td></tr>'; return; }
try {
const data = await request(`/admin/device/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
deviceRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">暂无登记设备</td></tr>';
rows.forEach(item => { const tr=document.createElement('tr'); tr.innerHTML=`<td>${item.device_id}</td><td><span class="badge ${item.disabled?'fail':'success'}">${item.disabled?'已禁用':'正常'}</span></td><td>${item.credential_seq}</td><td>${item.last_seen_at||''}</td><td>${item.last_ip||''}</td><td><button data-device-id="${item.device_id}" data-disabled="${item.disabled}">${item.disabled?'恢复':'禁用'}</button></td>`; deviceRows.appendChild(tr); });
} catch (err) { log(err); notify('读取设备列表失败', true); }
}
async function toggleDevice(deviceId, disabled) {
let reason=''; if (!disabled) { reason=window.prompt('请输入禁用原因', '管理员禁用') || ''; if (!reason) return; }
try { const result=await request('/admin/device/set-disabled',{method:'POST',contentType:'application/json',body:JSON.stringify({device_id:deviceId,disabled:!disabled,reason})}); log(result); notify(result.msg); await loadDevices(); } catch(err) { log(err); notify('设备状态修改失败',true); }
}
function formatLogBytes(bytes){const n=Number(bytes)||0;return n>=1048576?(n/1048576).toFixed(2)+' MB':n>=1024?(n/1024).toFixed(1)+' KB':n+' B';}
async function loadDownloadLogs(){const appId=currentAppId||publishAppIdInput.value.trim();try{const data=await request(`/admin/download-log/list?app_id=${encodeURIComponent(appId)}&limit=300`);const rows=data.list||[];downloadLogRows.innerHTML=rows.length?'':'<tr><td colspan="8" class="empty">暂无下载日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.device_id}</td><td>${x.license_id}</td><td>${x.version} / ${x.channel_code}</td><td>${x.file_path}</td><td>${formatLogBytes(x.file_size)}</td><td><span class="badge ${x.result==='success'?'success':x.result==='fail'?'fail':''}">${x.result}</span></td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;downloadLogRows.appendChild(tr);});}catch(err){log(err);notify('读取下载日志失败',true);}}
async function loadAuditLogs(){try{const data=await request('/admin/audit-log/list?limit=300');const rows=data.list||[];auditLogRows.innerHTML=rows.length?'':'<tr><td colspan="6" class="empty">暂无审计日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.actor_hash}</td><td>${x.method} ${x.path}</td><td><span class="badge ${x.result==='success'?'success':'fail'}">${x.result}</span></td><td>${x.status_code}</td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;auditLogRows.appendChild(tr);});}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; }
const previousToken = currentToken;
currentToken = token;
try {
await request('/admin/auth/check');
localStorage.setItem('admin_token', currentToken);
await loadConsoleData();
notify('令牌验证成功并已保存');
} catch (err) {
currentToken = previousToken;
updateTokenInput();
log(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 currentInputToken = adminTokenInput.value.trim();
const nextToken = newAdminTokenInput.value.trim();
const confirmation = confirmAdminTokenInput.value.trim();
if (!currentInputToken) { notify('请先输入当前管理员令牌', true); return; }
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
currentToken = currentInputToken;
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('管理员令牌已在当前服务进程生效,请同步修改 .env');
} catch (err) {
log(err);
const detail = err && err.body && err.body.detail ? err.body.detail : '请确认当前令牌正确';
notify('更改令牌失败:' + detail, true);
}
});
document.getElementById('refreshAppsBtn').addEventListener('click', loadApps);
document.getElementById('refreshChannelsBtn').addEventListener('click', loadChannels);
document.getElementById('saveChannelBtn').addEventListener('click', saveChannel);
channelRows.addEventListener('click',event=>{const b=event.target.closest('button[data-channel-code]');if(!b)return;const c=channels.find(x=>x.channel_code===b.dataset.channelCode);if(!c)return;document.getElementById('channelCode').value=c.channel_code;document.getElementById('channelName').value=c.display_name;document.getElementById('channelOrder').value=c.sort_order;document.getElementById('channelEnabled').checked=c.enabled;});
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('refreshDownloadLogsBtn').addEventListener('click', loadDownloadLogs);
document.getElementById('refreshAuditLogsBtn').addEventListener('click', loadAuditLogs);
document.getElementById('refreshDevicesBtn').addEventListener('click', loadDevices);
document.getElementById('refreshLicensesBtn').addEventListener('click', loadLicenses);
document.getElementById('createLicenseBtn').addEventListener('click', createLicense);
licenseRows.addEventListener('click', event=>{const b=event.target.closest('button[data-license-id]');if(b)toggleLicense(b.dataset.licenseId,b.dataset.status);});
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
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) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
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 === 'set-protocol') {
await setVersionProtocol(id, button.dataset.protocol);
} else if (action === 'offline-package') {
await downloadOfflinePackage(id, button.dataset.version);
} else if (action === 'delete') {
await deleteVersion(id);
}
});
async function loadConsoleData() {
await loadRuntimeConfig();
renderSelectedDirectory();
await loadApps();
await loadReports();
await loadLicenses();
await loadDevices();
await loadDownloadLogs();
await loadAuditLogs();
}
function renderLoggedOutState() {
versionRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示版本列表</td></tr>';
reportRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示升级日志</td></tr>';
downloadLogRows.innerHTML = '<tr><td colspan="8" class="empty">登录后显示下载日志</td></tr>';
auditLogRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示审计日志</td></tr>';
deviceRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示设备</td></tr>';
licenseRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示授权</td></tr>';
channelRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示渠道</td></tr>';
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
window.addEventListener('load', () => {
apiBaseLabel.textContent = apiBase;
updateTokenInput();
renderLoggedOutState();
});
</script>
</body>
</html>