623 lines
29 KiB
HTML
623 lines
29 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>
|
|||
|
|
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>
|