1080 lines
49 KiB
HTML
1080 lines
49 KiB
HTML
<!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>
|