Files
update-server/legacy/admin.html
T

2673 lines
104 KiB
HTML
Raw Normal View History

2026-07-09 09:25:04 +00:00
<!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;
}
.view[hidden] { display:none!important; }
.auth-view {
min-height:calc(100vh - 118px);
display:grid;
align-items:start;
padding-top:36px;
}
.login-panel {
width:min(760px,100%);
margin:0 auto;
}
.topbar {
display:flex;
align-items:center;
justify-content:space-between;
gap:14px;
margin:0 0 16px;
padding:14px 16px;
background:var(--surface);
border:1px solid var(--line);
border-radius:8px;
box-shadow:0 1px 3px rgba(16,24,40,.06);
}
.topbar-title {
display:flex;
flex-direction:column;
gap:2px;
}
.topbar-title strong { font-size:15px; }
.topbar-actions {
display:flex;
flex-wrap:wrap;
justify-content:flex-end;
gap:8px;
}
.topbar-actions button,
.security-actions 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;
}
.topbar-actions button:hover,
.security-actions button:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
.security-actions {
display:flex;
gap:10px;
margin-top:14px;
}
.login-form {
display:grid;
grid-template-columns:minmax(260px,1fr) auto auto;
align-items:end;
gap:12px;
margin-top:0;
}
.field-grid {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
gap:14px;
margin-top:12px;
}
.field {
display:grid;
gap:6px;
min-width:0;
}
.field label {
margin:0;
color:#344054;
font-size:13px;
font-weight:650;
line-height:1.25;
}
.field input,
.field select {
width:100%;
min-width:0;
max-width:none;
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;
}
.field input:focus,
.field select:focus {
border-color:#7893e8;
background:#fff;
box-shadow:0 0 0 3px rgba(49,87,213,.12);
}
.field-actions {
display:flex;
justify-content:flex-end;
gap:10px;
margin-top:14px;
}
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 auto;
min-width:max-content;
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 180px;
width:auto;
min-width:160px;
max-width:320px;
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:110px; max-width:150px; }
.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,
.login-form button,
.field-actions 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,
.login-form button,
.field-actions button {
flex:0 0 auto;
display:inline-flex;
align-items:center;
justify-content:center;
height:38px;
}
.action-row {
justify-content:flex-start;
padding-left:0;
}
.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; }
.inline-choice {
flex:0 0 auto!important;
display:inline-flex!important;
align-items:center;
gap:8px;
min-width:0!important;
min-height:36px;
padding:7px 11px;
color:#344054;
background:#f8fafc;
border:1px solid #cfd6e2;
border-radius:7px;
font-weight:650;
cursor:pointer;
}
.inline-choice input[type="radio"],
.row input[type="radio"] {
flex:0 0 auto;
width:16px;
min-width:16px;
height:16px;
margin:0;
padding:0;
box-shadow:none;
}
.publish-mode-row {
align-items:center;
padding:10px 12px;
background:#f8fafc;
border:1px solid #e2e8f0;
border-radius:8px;
}
.publish-source-row[hidden] { display:none!important; }
.protocol-hint {
flex:1 1 260px;
}
.row button:hover,
.login-form button:hover,
.field-actions button:hover,
td button:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
#saveTokenBtn,
#confirmChangeTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn,
#generateClientConfigBtn {
2026-07-09 09:25:04 +00:00
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#saveTokenBtn:hover,
#confirmChangeTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover,
#generateClientConfigBtn:hover { background:var(--brand-hover); }
2026-07-09 09:25:04 +00:00
.danger-btn {
color:#b42318;
border-color:#fecdca;
background:#fff5f5;
}
.danger-btn:hover {
border-color:#fda29b;
background:#fee4e2;
}
#openSecurityBtn,
#backDashboardBtn,
#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; }
.license-key-cell {
display:inline-block;
max-width:360px;
word-break:break-all;
font-family:"Cascadia Code",Consolas,monospace;
font-size:12px;
line-height:1.45;
}
.license-key-missing {
color:var(--muted);
font-family:inherit;
}
2026-07-09 09:25:04 +00:00
.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;
}
.log-section { overflow-x:visible; }
.log-heading {
display:flex;
flex-wrap:wrap;
align-items:center;
justify-content:space-between;
gap:10px 16px;
margin:0 0 12px;
padding-bottom:12px;
border-bottom:1px solid #edf0f4;
}
.log-heading h2 {
margin:0;
padding:0;
border:0;
}
.log-heading-actions {
display:inline-flex;
align-items:center;
gap:10px;
margin-left:auto;
}
.collapse-toggle {
min-height:34px;
padding:7px 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;
}
.collapse-toggle:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
.collapsible-body {
overflow-x:auto;
}
.collapsible-body[hidden] {
display:none!important;
}
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; }
.topbar { align-items:stretch; flex-direction:column; }
.topbar-actions { justify-content:flex-start; }
.auth-view { min-height:auto; padding-top:12px; }
.login-form,
.field-grid {
grid-template-columns:1fr;
}
.field-actions {
flex-direction:column;
justify-content:stretch;
}
.login-form button,
.field-actions button {
width:100%;
}
.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; justify-content:stretch; }
.row input[type="checkbox"] { width:16px; min-width:16px; }
.inline-choice {
justify-content:flex-start;
width:100%;
}
.row input[type="radio"] { width:16px; min-width:16px; }
table { min-width:760px; }
.toast { right:12px; left:12px; max-width:none; }
}
/* Console polish: keep behavior, improve admin-console structure. */
:root {
--bg:#eef2f6;
--surface:#ffffff;
--surface-subtle:#f8fafc;
--ink:#101828;
--muted:#667085;
--line:#d7dee9;
--field:#ffffff;
--brand:#2563eb;
--brand-hover:#1d4ed8;
--accent:#0f766e;
--danger:#c2410c;
--success:#047857;
--warning:#a16207;
--shadow:0 1px 2px rgba(16,24,40,.06),0 12px 28px rgba(16,24,40,.08);
}
html { scroll-behavior:smooth; }
body {
max-width:none;
min-width:320px;
padding:0 24px 40px;
background:linear-gradient(180deg,#f8fafc 0,#eef2f6 260px,#eef2f6 100%);
color:var(--ink);
}
body::before {
content:"";
position:fixed;
inset:0 0 auto 0;
height:3px;
z-index:40;
background:linear-gradient(90deg,var(--brand),var(--accent));
}
h1 {
position:sticky;
top:0;
z-index:30;
max-width:none;
margin:0 -24px 22px;
padding:18px 28px 17px;
background:rgba(255,255,255,.94);
border-bottom:1px solid rgba(215,222,233,.9);
box-shadow:0 1px 0 rgba(16,24,40,.04),0 8px 24px rgba(16,24,40,.06);
backdrop-filter:blur(12px);
font-size:22px;
font-weight:760;
}
h1::before {
margin-bottom:4px;
color:var(--accent);
letter-spacing:0;
font-size:11px;
}
h1::after {
content:"软件发布与升级管理";
display:inline-flex;
margin-left:12px;
padding:4px 9px;
color:#344054;
background:#eef4ff;
border:1px solid #d7e3ff;
border-radius:999px;
font-size:12px;
font-weight:650;
vertical-align:middle;
}
#loginView,
#dashboardView,
#securityView {
width:min(1500px,100%);
margin:0 auto;
}
.auth-view {
min-height:calc(100vh - 98px);
align-items:center;
padding-top:0;
}
.login-panel {
width:min(640px,100%);
}
.dashboard-shell {
display:grid;
grid-template-columns:232px minmax(0,1fr);
gap:18px;
align-items:start;
}
.dashboard-main {
min-width:0;
}
.side-nav {
position:sticky;
top:96px;
max-height:calc(100vh - 120px);
padding:12px;
overflow:auto;
background:rgba(255,255,255,.96);
border:1px solid var(--line);
border-radius:10px;
box-shadow:0 1px 2px rgba(16,24,40,.05);
}
.side-nav-title {
padding:6px 9px 10px;
color:#475467;
font-size:12px;
font-weight:760;
}
.side-nav a {
display:flex;
align-items:center;
justify-content:space-between;
min-height:36px;
margin:2px 0;
padding:8px 10px;
color:#344054;
text-decoration:none;
border:1px solid transparent;
border-radius:7px;
font-weight:650;
transition:background .16s ease,border-color .16s ease,color .16s ease;
}
.side-nav a::after {
content:"";
width:6px;
height:6px;
border-radius:50%;
background:#d0d5dd;
}
.side-nav a:hover {
color:var(--brand);
background:#f5f8ff;
border-color:#d7e3ff;
}
.side-nav a:hover::after { background:var(--brand); }
.topbar {
position:sticky;
top:76px;
z-index:20;
margin-bottom:18px;
padding:14px 16px;
border-radius:10px;
background:rgba(255,255,255,.94);
backdrop-filter:blur(10px);
box-shadow:0 1px 2px rgba(16,24,40,.05);
}
.topbar-title strong {
font-size:16px;
font-weight:760;
}
.section {
scroll-margin-top:148px;
margin-bottom:16px;
padding:0;
overflow:hidden;
border:1px solid var(--line);
border-radius:10px;
box-shadow:0 1px 2px rgba(16,24,40,.04);
}
.section:hover {
border-color:#c8d2df;
box-shadow:0 1px 2px rgba(16,24,40,.05),0 10px 24px rgba(16,24,40,.06);
}
h2,
.log-heading {
margin:0;
padding:15px 18px;
background:linear-gradient(180deg,#ffffff,#fbfcfe);
border-bottom:1px solid #e6ebf2;
}
h2 {
display:flex;
align-items:center;
gap:9px;
font-size:15px;
font-weight:760;
}
h2::before,
.log-heading h2::before {
content:"";
width:4px;
height:18px;
flex:0 0 4px;
border-radius:99px;
background:linear-gradient(180deg,var(--brand),var(--accent));
}
.log-heading h2 {
padding:0;
background:transparent;
}
.section > .row,
.token-change,
.section > pre {
margin-left:18px;
margin-right:18px;
}
.section h2 + .row { margin-top:14px; }
.section > .row:last-of-type { margin-bottom:14px; }
.row {
gap:10px 14px;
}
.row input,
.row select,
.field input,
.field select {
height:36px;
border-color:#cbd5e1;
border-radius:7px;
box-shadow:inset 0 1px 1px rgba(16,24,40,.03);
}
.row input:hover,
.row select:hover,
.field input:hover,
.field select:hover {
border-color:#98a2b3;
}
.row button,
.login-form button,
.field-actions button,
.topbar-actions button,
.security-actions button,
.collapse-toggle,
td button {
min-height:34px;
height:auto;
border-radius:7px;
box-shadow:0 1px 1px rgba(16,24,40,.03);
}
#saveTokenBtn,
#confirmChangeTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn,
#generateClientConfigBtn {
2026-07-09 09:25:04 +00:00
background:var(--brand);
border-color:var(--brand);
}
.danger-btn,
td button[data-action="delete"] {
color:#b42318;
border-color:#fecaca;
background:#fff7ed;
}
table {
width:calc(100% - 36px);
min-width:760px;
margin:14px 18px 18px;
border-color:#dbe3ef;
border-radius:8px;
box-shadow:none;
}
th,
td {
padding:10px 12px;
}
th {
position:sticky;
top:0;
z-index:1;
color:#475467;
background:#f3f6fa;
border-bottom:1px solid #dfe7f1;
}
td {
color:#1f2937;
font-size:13px;
}
tbody tr:nth-child(even) { background:#fcfdff; }
tbody tr:hover { background:#f4f8ff; }
.badge {
border:1px solid transparent;
border-radius:6px;
}
.badge.success { border-color:#bbf7d0; }
.badge.fail { border-color:#fecaca; }
.small {
color:#667085;
}
.log-section { overflow:hidden; }
.log-heading {
margin:0;
}
.collapsible-body {
padding:0 18px 18px;
overflow-x:auto;
}
.collapsible-body > .row { margin:14px 0 0; }
.collapsible-body table {
width:100%;
margin:14px 0 0;
}
pre {
width:calc(100% - 36px);
margin:14px 18px 18px;
border:1px solid #1f2a44;
border-radius:8px;
background:#111827;
}
.token-change {
margin-top:14px;
margin-bottom:18px;
padding:16px;
background:#f8fafc;
border-color:#dbe3ef;
}
.toast {
border-radius:8px;
box-shadow:var(--shadow);
}
@media(max-width:1180px) {
body { padding:0 16px 36px; }
h1 { margin-left:-16px; margin-right:-16px; }
.dashboard-shell { grid-template-columns:1fr; }
.side-nav {
position:static;
max-height:none;
display:flex;
flex-wrap:wrap;
gap:6px;
}
.side-nav-title { width:100%; padding-bottom:4px; }
.side-nav a { margin:0; }
.side-nav a::after { display:none; }
.topbar { top:70px; }
}
@media(max-width:820px) {
body { padding:0 12px 32px; }
h1 { margin:0 -12px 14px; padding:16px 14px; font-size:20px; }
h1::after { margin:8px 0 0; }
.section > .row,
.token-change,
.section > pre { margin-left:14px; margin-right:14px; }
table { width:calc(100% - 28px); margin-left:14px; margin-right:14px; }
.collapsible-body { padding-left:14px; padding-right:14px; }
.side-nav { padding:10px; }
.side-nav a { flex:1 1 128px; }
}
/* Visual boost: command-center mood without changing behavior. */
:root {
--bg:#edf2f4;
--surface:#ffffff;
--ink:#0b1220;
--muted:#607084;
--line:#cfd9e6;
--brand:#1f6feb;
--brand-hover:#1557c0;
--accent:#0d9488;
--energy:#f59e0b;
--danger:#dc2626;
--success:#059669;
--shadow:0 16px 38px rgba(15,23,42,.10),0 1px 2px rgba(15,23,42,.06);
}
body {
background:
linear-gradient(rgba(15,23,42,.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(15,23,42,.045) 1px, transparent 1px),
linear-gradient(135deg,#f8fbff 0%,#edf2f4 42%,#f5f7f2 100%);
background-size:28px 28px,28px 28px,auto;
}
body::before {
height:4px;
background:linear-gradient(90deg,#0d9488,#1f6feb 46%,#f59e0b);
box-shadow:0 0 18px rgba(31,111,235,.55);
}
body::after {
content:"";
position:fixed;
inset:0;
z-index:-1;
pointer-events:none;
background:
linear-gradient(120deg,transparent 0 34%,rgba(13,148,136,.08) 34% 34.25%,transparent 34.25% 100%),
linear-gradient(60deg,transparent 0 62%,rgba(245,158,11,.08) 62% 62.25%,transparent 62.25% 100%);
}
h1 {
color:#f8fafc;
background:
linear-gradient(90deg,rgba(255,255,255,.08) 0 1px,transparent 1px),
linear-gradient(135deg,#111827 0%,#123532 48%,#3a2f12 100%);
background-size:18px 18px,auto;
border-bottom:1px solid rgba(148,163,184,.24);
box-shadow:0 18px 40px rgba(15,23,42,.22);
}
h1::before {
color:#7dd3fc;
text-shadow:0 0 14px rgba(45,212,191,.4);
}
h1::after {
color:#d9f99d;
background:rgba(15,23,42,.42);
border-color:rgba(125,211,252,.28);
box-shadow:inset 0 0 0 1px rgba(255,255,255,.05),0 0 18px rgba(13,148,136,.22);
}
.topbar {
color:#e5edf6;
background:linear-gradient(135deg,rgba(17,24,39,.94),rgba(18,53,50,.92));
border-color:rgba(125,211,252,.18);
box-shadow:0 14px 34px rgba(15,23,42,.18),inset 0 1px 0 rgba(255,255,255,.08);
}
.topbar-title strong { color:#f8fafc; }
.topbar .small { color:#b9c7d8; }
.topbar .small strong { color:#d9f99d; }
.topbar-actions button {
color:#e5edf6;
background:rgba(255,255,255,.08);
border-color:rgba(226,232,240,.24);
}
.topbar-actions button:hover {
background:rgba(255,255,255,.14);
border-color:rgba(125,211,252,.48);
box-shadow:0 0 0 3px rgba(14,165,233,.12),0 10px 22px rgba(0,0,0,.18);
}
.side-nav {
background:
linear-gradient(180deg,rgba(17,24,39,.98),rgba(20,37,44,.98)),
linear-gradient(rgba(255,255,255,.05) 1px,transparent 1px);
border-color:rgba(125,211,252,.18);
box-shadow:0 20px 44px rgba(15,23,42,.18),inset 0 1px 0 rgba(255,255,255,.08);
}
.side-nav-title {
color:#a7f3d0;
letter-spacing:.08em;
text-transform:uppercase;
}
.side-nav a {
position:relative;
color:#dbeafe;
border-color:rgba(255,255,255,.06);
background:rgba(255,255,255,.035);
}
.side-nav a::before {
content:"";
width:8px;
height:8px;
margin-right:9px;
flex:0 0 8px;
border-radius:2px;
background:#22c55e;
box-shadow:0 0 14px rgba(34,197,94,.55);
}
.side-nav a::after {
width:18px;
height:2px;
border-radius:99px;
background:rgba(125,211,252,.35);
}
.side-nav a:hover {
color:#ffffff;
background:linear-gradient(90deg,rgba(31,111,235,.26),rgba(13,148,136,.18));
border-color:rgba(125,211,252,.48);
box-shadow:inset 3px 0 0 #38bdf8,0 0 18px rgba(31,111,235,.18);
}
.section {
position:relative;
background:rgba(255,255,255,.94);
border-color:rgba(148,163,184,.36);
box-shadow:0 1px 2px rgba(15,23,42,.06),0 16px 34px rgba(15,23,42,.07);
}
.section::before {
content:"";
position:absolute;
inset:0 0 auto 0;
height:2px;
background:linear-gradient(90deg,var(--accent),var(--brand),var(--energy));
opacity:.85;
}
.section:hover {
transform:translateY(-1px);
border-color:rgba(31,111,235,.35);
box-shadow:0 18px 44px rgba(15,23,42,.12),0 0 0 1px rgba(31,111,235,.04);
}
h2,
.log-heading {
background:
linear-gradient(90deg,rgba(248,250,252,.96),rgba(238,246,255,.92) 58%,rgba(255,251,235,.82));
}
h2::before,
.log-heading h2::before {
background:linear-gradient(180deg,#22d3ee,#1f6feb 55%,#f59e0b);
box-shadow:0 0 14px rgba(31,111,235,.32);
}
.row input,
.row select,
.field input,
.field select {
background:linear-gradient(180deg,#fff,#f8fafc);
border-color:#bac7d6;
}
.row input:focus,
.row select:focus,
.field input:focus,
.field select:focus {
border-color:#1f6feb;
box-shadow:0 0 0 3px rgba(31,111,235,.14),0 0 18px rgba(13,148,136,.12);
}
.row button,
.login-form button,
.field-actions button,
.collapse-toggle,
td button {
border-color:#b9c6d8;
background:linear-gradient(180deg,#fff,#f5f8fc);
}
.row button:hover,
.login-form button:hover,
.field-actions button:hover,
.collapse-toggle:hover,
td button:hover {
border-color:#1f6feb;
box-shadow:0 0 0 3px rgba(31,111,235,.10),0 10px 22px rgba(15,23,42,.10);
}
#saveTokenBtn,
#confirmChangeTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn,
#generateClientConfigBtn {
2026-07-09 09:25:04 +00:00
background:linear-gradient(135deg,#1f6feb,#0d9488 70%,#10b981);
border-color:transparent;
color:#fff;
box-shadow:0 8px 18px rgba(31,111,235,.25),inset 0 1px 0 rgba(255,255,255,.22);
}
#saveTokenBtn:hover,
#confirmChangeTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover,
#generateClientConfigBtn:hover {
2026-07-09 09:25:04 +00:00
background:linear-gradient(135deg,#1557c0,#0f766e 72%,#059669);
box-shadow:0 0 0 3px rgba(13,148,136,.16),0 12px 26px rgba(31,111,235,.28);
}
.danger-btn,
td button[data-action="delete"] {
color:#991b1b;
background:linear-gradient(180deg,#fff7ed,#fee2e2);
border-color:#fecaca;
}
table {
border-color:#cbd5e1;
box-shadow:inset 0 1px 0 rgba(255,255,255,.6);
}
th {
color:#e2e8f0;
background:linear-gradient(180deg,#1f2937,#263443);
border-bottom-color:#334155;
}
tbody tr:nth-child(even) { background:#f8fbff; }
tbody tr:hover {
background:#edf7ff;
box-shadow:inset 3px 0 0 #0ea5e9;
}
.badge.success {
color:#065f46;
background:linear-gradient(180deg,#ecfdf5,#d1fae5);
box-shadow:0 0 0 1px rgba(16,185,129,.14);
}
.badge.fail {
color:#991b1b;
background:linear-gradient(180deg,#fff7ed,#fee2e2);
box-shadow:0 0 0 1px rgba(239,68,68,.14);
}
pre {
color:#dbeafe;
background:
linear-gradient(rgba(125,211,252,.06) 1px,transparent 1px),
linear-gradient(90deg,rgba(125,211,252,.04) 1px,transparent 1px),
#0b1220;
background-size:22px 22px,22px 22px,auto;
border-color:rgba(125,211,252,.22);
box-shadow:inset 0 0 32px rgba(14,165,233,.08);
}
.login-panel.section {
box-shadow:0 24px 54px rgba(15,23,42,.16),0 0 0 1px rgba(255,255,255,.55);
}
.toast {
background:linear-gradient(135deg,#111827,#0f3b38);
border:1px solid rgba(125,211,252,.22);
}
@media(max-width:1180px) {
.side-nav {
background:rgba(17,24,39,.96);
}
.side-nav a::before { display:none; }
}
/* Friendly operation feedback. */
.toast {
display:grid;
grid-template-columns:auto 1fr;
align-items:start;
gap:10px;
min-width:min(420px,calc(100vw - 24px));
color:#eef6ff;
}
.toast::before {
content:"提示";
display:inline-flex;
align-items:center;
justify-content:center;
min-width:42px;
height:22px;
padding:0 7px;
border-radius:999px;
color:#cffafe;
background:rgba(14,165,233,.22);
font-size:12px;
font-weight:760;
}
.toast.success::before {
content:"成功";
color:#dcfce7;
background:rgba(34,197,94,.24);
}
.toast.info::before {
content:"提示";
color:#dbeafe;
background:rgba(59,130,246,.24);
}
.toast.error::before {
content:"错误";
color:#fee2e2;
background:rgba(239,68,68,.24);
}
.toast.success {
background:linear-gradient(135deg,#064e3b,#0f766e);
border-color:rgba(134,239,172,.28);
}
.toast.info {
background:linear-gradient(135deg,#0f172a,#1e3a8a);
border-color:rgba(147,197,253,.28);
}
.toast.error {
background:linear-gradient(135deg,#7f1d1d,#991b1b);
border-color:rgba(252,165,165,.28);
}
/* Larger login panel, aligned with security settings breathing room. */
#loginView.auth-view {
min-height:calc(100vh - 92px);
padding:34px 0 54px;
}
#loginView .login-panel {
width:min(820px,100%);
padding:0;
overflow:hidden;
}
#loginView .login-panel h2 {
padding:22px 28px;
font-size:17px;
}
#loginView .login-form {
grid-template-columns:minmax(320px,1fr) 84px 132px;
gap:14px;
padding:28px 28px 18px;
margin:0;
}
#loginView .field label {
margin-bottom:2px;
color:#475569;
}
#loginView .field input {
height:44px;
font-size:15px;
}
#loginView .login-form button {
min-height:44px;
height:44px;
padding:9px 16px;
}
#loginView .login-panel > .small {
display:flex;
margin:0 28px;
padding:8px 0;
}
#loginView .login-panel > .small:last-child {
margin-bottom:22px;
padding-bottom:0;
}
@media(max-width:820px) {
#loginView.auth-view { padding-top:16px; }
#loginView .login-panel h2 { padding:18px 18px; }
#loginView .login-form {
grid-template-columns:1fr;
padding:20px 18px 12px;
}
#loginView .login-panel > .small {
margin-left:18px;
margin-right:18px;
}
}
</style>
</head>
<body><div id="toast" class="toast"></div>
<h1>软件发布控制台</h1>
<div id="loginView" class="view auth-view">
<div class="section login-panel">
<h2>管理员登录</h2>
<div class="login-form">
<div class="field">
<label for="adminToken">管理员令牌</label>
<input id="adminToken" type="password" autocomplete="off" placeholder="请输入管理员令牌" />
</div>
<button id="toggleTokenBtn" type="button" aria-label="显示令牌">显示</button>
<button id="saveTokenBtn" type="button">登录并进入</button>
</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验。</div>
<div class="small">当前连接服务端:<strong id="apiBaseLabel"></strong></div>
</div>
</div>
<div id="dashboardView" class="view" hidden>
<div class="topbar">
<div class="topbar-title">
<strong>管理控制台</strong>
<span class="small">当前连接服务端:<strong id="dashboardApiBaseLabel"></strong></span>
</div>
<div class="topbar-actions">
<button id="openSecurityBtn" type="button">安全设置</button>
<button id="logoutBtn" class="danger-btn" type="button">退出登录</button>
</div>
</div>
<div class="dashboard-shell">
<aside class="side-nav" aria-label="控制台导航">
<div class="side-nav-title">控制台模块</div>
<a href="#sec-app">应用管理</a>
<a href="#sec-channel">渠道管理</a>
<a href="#sec-publish">发布新版本</a>
<a href="#sec-versions">版本列表</a>
<a href="#sec-policy">运行策略</a>
<a href="#sec-license">License 授权</a>
<a href="#sec-client-config">客户端配置</a>
2026-07-09 09:25:04 +00:00
<a href="#sec-devices">设备管理</a>
<a href="#sec-crash-report">崩溃报告</a>
<a href="#sec-report-log">升级日志</a>
<a href="#sec-download-log">下载日志</a>
<a href="#sec-audit-log">审计日志</a>
<a href="#sec-debug">调试输出</a>
</aside>
<main class="dashboard-main">
<div id="sec-app" 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 id="sec-channel" 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 id="sec-publish" 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" aria-describedby="publishProtocolHint" />
<span id="publishProtocolHint" class="small protocol-hint">默认 3;表示客户端升级能力,普通发布不用改。</span>
</div>
<div class="row publish-mode-row">
<label>发布方式</label>
<label class="inline-choice"><input id="publishModeDirectory" name="publishMode" type="radio" value="directory" checked />软件根目录</label>
<label class="inline-choice"><input id="publishModeArchive" name="publishMode" type="radio" value="archive" />压缩发布包</label>
<span class="small">二选一;压缩包上传后由服务端解压并校验主程序路径。</span>
</div>
<div id="publishDirectoryRow" class="row publish-source-row">
<label for="publishFiles">软件根目录</label>
<input id="publishFiles" type="file" webkitdirectory directory multiple />
<span class="small">选择包含 bin/SimCAE.exe 的 SimCAE 发布根目录。</span>
</div>
<div id="publishArchiveRow" class="row publish-source-row" hidden>
<label for="publishArchive">压缩发布包</label>
<input id="publishArchive" type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar,application/zip,application/x-tar,application/gzip,application/x-bzip2,application/vnd.rar" />
<span class="small">支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar。</span>
</div>
<div class="row action-row">
<span id="publishFileSummary" class="small">请选择包含 bin/SimCAE.exe 的发布根目录,或上传 zip/tar.gz/tgz/tar.bz2/rar 发布包</span>
<button id="publishBtn" type="button">发布版本</button>
</div>
<pre id="publishFilePreview">尚未选择发布目录或压缩包</pre>
</div>
<div id="sec-versions" 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 id="sec-policy" 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 id="sec-license" 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="创建后会自动填入客户端配置生成区,也可在下方列表再次查看"></div>
<table><thead><tr><th>License ID</th><th>客户</th><th>渠道</th><th>设备</th><th>有效期</th><th>License</th><th>状态</th><th>操作</th></tr></thead><tbody id="licenseRows"></tbody></table>
</div>
<div id="sec-client-config" class="section">
<h2>客户端配置生成</h2>
<div class="row">
<span class="small">根据当前应用、渠道、版本号和服务端令牌生成配套 app_config.json。</span>
</div>
<div class="row">
<label for="configApiBaseUrl">服务端 API 地址</label>
<input id="configApiBaseUrl" class="wide" placeholder="例如 http://192.168.229.128:8000">
</div>
<div class="row">
<label for="configMainExecutable">主程序文件名</label>
<input id="configMainExecutable" class="wide" placeholder="例如 SimCAE.exe 或 MainApp.exe">
</div>
<div class="row">
<label for="configLicenseKey">License</label>
<input id="configLicenseKey" class="wide" placeholder="可粘贴后台创建的 License;留空时客户端首次启动会提示输入">
</div>
<div class="row action-row">
<button id="generateClientConfigBtn" type="button">生成配套配置</button>
<button id="copyClientConfigBtn" type="button" disabled>复制配置</button>
<span id="clientConfigSummary" class="small">生成后复制到客户端 bin/config/app_config.json。</span>
</div>
<pre id="generatedClientConfig">尚未生成客户端配置</pre>
2026-07-09 09:25:04 +00:00
</div>
<div id="sec-devices" 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 id="sec-crash-report" class="section">
<h2>崩溃报告</h2>
<div class="row">
<button id="refreshCrashReportsBtn" type="button">刷新崩溃报告</button>
<span id="crashReportCount" class="small">登录后加载</span>
<span class="small">可下载 metadata、dmp、attachments 和 server.json 原始文件。</span>
</div>
<table>
<thead>
<tr>
<th>操作</th>
<th>报告 ID</th>
<th>版本</th>
<th>Git</th>
<th>构建</th>
<th>渠道</th>
<th>异常码</th>
<th>符号化</th>
<th>崩溃时间</th>
<th>接收时间</th>
<th>IP</th>
<th>大小</th>
</tr>
</thead>
<tbody id="crashReportRows"></tbody>
</table>
</div>
<div id="sec-report-log" class="section log-section">
<div class="log-heading">
<h2>升级日志</h2>
<div class="log-heading-actions">
<span id="reportLogCount" class="small">登录后加载</span>
<button id="toggleReportLogBtn" class="collapse-toggle" type="button" aria-controls="reportLogBody" aria-expanded="false">展开</button>
</div>
</div>
<div id="reportLogBody" class="collapsible-body" hidden>
<div class="row">
<button id="refreshReportsBtn" type="button">刷新日志</button>
<button id="clearReportsBtn" class="danger-btn" type="button">清空升级日志</button>
</div>
<table>
<thead>
<tr>
<th>设备</th>
<th>旧版本</th>
<th>新版本</th>
<th>结果</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reportRows"></tbody>
</table>
</div>
</div>
<div id="sec-download-log" class="section log-section">
<div class="log-heading">
<h2>文件下载日志</h2>
<div class="log-heading-actions">
<span id="downloadLogCount" class="small">登录后加载</span>
<button id="toggleDownloadLogBtn" class="collapse-toggle" type="button" aria-controls="downloadLogBody" aria-expanded="false">展开</button>
</div>
</div>
<div id="downloadLogBody" class="collapsible-body" hidden>
<div class="row"><button id="refreshDownloadLogsBtn">刷新下载日志</button><button id="clearDownloadLogsBtn" class="danger-btn" type="button">清空下载日志</button></div>
<table><thead><tr><th>操作</th><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>
<div id="sec-audit-log" class="section">
<h2>管理员审计日志</h2><div class="row"><button id="refreshAuditLogsBtn">刷新审计日志</button><button id="clearAuditLogsBtn" class="danger-btn" type="button">清空审计日志</button></div>
<table><thead><tr><th>管理员指纹</th><th>操作</th><th>结果</th><th>状态码</th><th>IP</th><th>时间</th><th>操作</th></tr></thead><tbody id="auditLogRows"></tbody></table>
</div>
<div id="sec-debug" class="section">
<h2>调试输出</h2>
<pre id="output">准备就绪...</pre>
</div>
</main>
</div>
</div>
<div id="securityView" class="view" hidden>
<div class="topbar">
<div class="topbar-title">
<strong>安全设置</strong>
<span class="small">修改管理员令牌;后端接口仍会校验当前登录令牌。</span>
</div>
<div class="topbar-actions">
<button id="backDashboardBtn" type="button">返回控制台</button>
<button id="securityLogoutBtn" class="danger-btn" type="button">退出登录</button>
</div>
</div>
<div class="section login-panel">
<h2>更改管理员令牌</h2>
<div id="tokenChangePanel" class="token-change">
<div class="field-grid">
<div class="field">
<label for="newAdminToken">新令牌</label>
<input id="newAdminToken" type="password" autocomplete="new-password" placeholder="至少 8 个字符" />
</div>
<div class="field">
<label for="confirmAdminToken">确认新令牌</label>
<input id="confirmAdminToken" type="password" autocomplete="new-password" placeholder="再次输入新令牌" />
</div>
</div>
<div class="field-actions">
<button id="confirmChangeTokenBtn" type="button">确认更改</button>
<button id="cancelChangeTokenBtn" type="button">清空输入</button>
</div>
</div>
</div>
</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 dashboardApiBaseLabel = document.getElementById('dashboardApiBaseLabel');
const loginView = document.getElementById('loginView');
const dashboardView = document.getElementById('dashboardView');
const securityView = document.getElementById('securityView');
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 publishArchiveInput = document.getElementById('publishArchive');
const publishModeInputs = Array.from(document.querySelectorAll('input[name="publishMode"]'));
const publishDirectoryRow = document.getElementById('publishDirectoryRow');
const publishArchiveRow = document.getElementById('publishArchiveRow');
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 configApiBaseUrlInput = document.getElementById('configApiBaseUrl');
const configMainExecutableInput = document.getElementById('configMainExecutable');
const configLicenseKeyInput = document.getElementById('configLicenseKey');
const generatedClientConfig = document.getElementById('generatedClientConfig');
const clientConfigSummary = document.getElementById('clientConfigSummary');
const copyClientConfigBtn = document.getElementById('copyClientConfigBtn');
2026-07-09 09:25:04 +00:00
const crashReportRows = document.getElementById('crashReportRows');
const downloadLogRows = document.getElementById('downloadLogRows');
const auditLogRows = document.getElementById('auditLogRows');
const reportLogBody = document.getElementById('reportLogBody');
const downloadLogBody = document.getElementById('downloadLogBody');
const toggleReportLogBtn = document.getElementById('toggleReportLogBtn');
const toggleDownloadLogBtn = document.getElementById('toggleDownloadLogBtn');
const reportLogCount = document.getElementById('reportLogCount');
const crashReportCount = document.getElementById('crashReportCount');
const downloadLogCount = document.getElementById('downloadLogCount');
let currentToken = localStorage.getItem('admin_token') || '';
let apps = [];
let currentAppId = '';
let channels = [];
let latestLicenses = [];
let runtimeClientConfig = {};
let latestClientConfigText = '';
let clientMainExecutableTouched = false;
2026-07-09 09:25:04 +00:00
function showView(name) {
loginView.hidden = name !== 'login';
dashboardView.hidden = name !== 'dashboard';
securityView.hidden = name !== 'security';
}
function clearSecurityInputs() {
newAdminTokenInput.value = '';
confirmAdminTokenInput.value = '';
}
function logout() {
currentToken = '';
localStorage.removeItem('admin_token');
updateTokenInput();
clearSecurityInputs();
renderLoggedOutState();
showView('login');
notify('已退出登录');
}
function setCollapsibleState(body, button, collapsed) {
body.hidden = collapsed;
button.textContent = collapsed ? '展开' : '收起';
button.setAttribute('aria-expanded', String(!collapsed));
}
function toggleCollapsibleSection(body, button) {
setCollapsibleState(body, button, !body.hidden);
}
function updateLogCount(element, count, emptyText) {
element.textContent = count > 0 ? `共 ${count} 条` : emptyText;
}
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, type = 'success', duration = 3200) {
const level = type === true ? 'error' : (type || 'success');
toast.textContent = message;
toast.className = `toast show ${level}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = "toast"; }, duration);
}
function friendlyErrorMessage(err, fallback = '操作失败') {
const body = err && err.body;
const detail = body && body.detail !== undefined ? body.detail : body;
if (detail && typeof detail === 'object') {
const msg = detail.msg || detail.message || detail.error;
if (msg) {
const available = detail.available_mb !== undefined ? `;可用 ${detail.available_mb} MB` : '';
const required = detail.required_mb !== undefined ? `;需要 ${detail.required_mb} MB` : '';
return `${msg}${required}${available}`;
}
return JSON.stringify(detail);
}
if (typeof detail === 'string' && detail) return detail;
if (err && err.message) return err.message;
return fallback;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
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 currentAppName() {
const appId = currentAppId || publishAppIdInput.value.trim();
const item = apps.find(app => app.app_id === appId);
return item ? item.app_name : appId;
}
function inferredMainExecutable() {
const appName = currentAppName().trim();
const candidate = appName || (currentAppId || publishAppIdInput.value.trim());
const cleaned = candidate.replace(/[^A-Za-z0-9_.-]/g, '');
return cleaned ? (cleaned.toLowerCase().endsWith('.exe') ? cleaned : `${cleaned}.exe`) : '';
}
function syncClientConfigDefaults(force = false) {
if (force || !configMainExecutableInput.value.trim() || !clientMainExecutableTouched) {
configMainExecutableInput.value = inferredMainExecutable()
|| runtimeClientConfig.main_executable
|| 'SimCAE.exe';
clientMainExecutableTouched = false;
}
}
2026-07-09 09:25:04 +00:00
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 = '未选择';
}
syncClientConfigDefaults();
2026-07-09 09:25:04 +00:00
}
async function loadApps() {
log('正在加载应用列表...');
try {
const data = await request('/admin/app/list');
apps = data.list || [];
updateAppSelection();
log({ apps });
} catch (err) {
log({ error: '加载应用列表失败', err });
notify('加载应用列表失败:' + friendlyErrorMessage(err), true);
}
}
async function addApp() {
const appId = document.getElementById('newAppId').value.trim();
const appName = document.getElementById('newAppName').value.trim();
if (!appId || !appName) {
log('请输入 App ID 和 App 名称');
notify('请先填写 App ID 和 App 名称', 'info');
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 = '';
notify(`应用 ${appId} 已创建`);
await loadApps();
} catch (err) {
log({ error: '新增应用失败', err });
notify('新增应用失败:' + friendlyErrorMessage(err), true);
}
}
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 = 'bin/SimCAE.exe';
let publishMaxRequestBytes = 4096 * 1024 * 1024;
let publishMaxFiles = 20000;
const supportedPublishArchives = ['.zip', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.rar'];
function isSupportedPublishArchive(file) {
const name = (file && file.name ? file.name : '').toLowerCase();
return supportedPublishArchives.some(ext => name.endsWith(ext));
}
function currentPublishMode() {
const selected = publishModeInputs.find(input => input.checked);
return selected ? selected.value : 'directory';
}
function updatePublishMode() {
const archiveMode = currentPublishMode() === 'archive';
publishDirectoryRow.hidden = archiveMode;
publishArchiveRow.hidden = !archiveMode;
if (archiveMode) {
publishFilesInput.value = '';
} else {
publishArchiveInput.value = '';
}
renderSelectedDirectory();
}
function selectedUploadEntries() {
const runtimeProtectedFiles = new Set(['client.ini', 'bootstrap.exe', 'config/app_config.json', 'config/local_state.json', 'config/client_identity.dat', 'config/version_policy.dat']);
const isRuntimeProtectedFile = (path) => {
if (runtimeProtectedFiles.has(path)) return true;
if (path.startsWith('bin/') && runtimeProtectedFiles.has(path.slice(4))) return true;
return false;
};
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 !isRuntimeProtectedFile(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.replaceAll(chrBackslash, '/').replace(/^\/+|\/+$/g, '').toLowerCase();
if (!lowerPaths.includes(mainName)) {
const nestedMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (nestedMain) {
return '选择层级过高:' + releaseMainExecutable + ' 位于更深层目录 ' + nestedMain + ',请选择包含该相对路径的发布根目录';
}
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;
publishMaxRequestBytes = Number(data.publish_max_request_bytes || publishMaxRequestBytes);
publishMaxFiles = Number(data.publish_max_files || publishMaxFiles);
runtimeClientConfig = data.default_client_config || {};
if (!configApiBaseUrlInput.value.trim()) {
configApiBaseUrlInput.value = data.api_base_url || runtimeClientConfig.api_base_url || apiBase;
}
syncClientConfigDefaults();
2026-07-09 09:25:04 +00:00
renderSelectedDirectory();
} catch (err) {
log({ warning: '读取发布配置失败,将使用默认主程序名', details: err.message || err });
}
}
function renderSelectedDirectory() {
const archiveMode = currentPublishMode() === 'archive';
const archiveFile = archiveMode && publishArchiveInput.files && publishArchiveInput.files.length ? publishArchiveInput.files[0] : null;
const allCount = archiveMode ? 0 : publishFilesInput.files.length;
if (archiveMode) {
let validationError = archiveFile ? '' : '请选择压缩发布包';
if (!validationError && !isSupportedPublishArchive(archiveFile)) {
validationError = '压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar';
}
if (!validationError && publishMaxRequestBytes > 0 && archiveFile.size > publishMaxRequestBytes) {
validationError = '压缩发布包过大:' + formatLogBytes(archiveFile.size) + ',当前上限为 ' + formatLogBytes(publishMaxRequestBytes);
}
publishBtn.disabled = Boolean(validationError);
publishFileSummary.textContent = validationError || ('将上传压缩发布包:' + archiveFile.name + ',大小 ' + formatLogBytes(archiveFile.size));
publishFileSummary.style.color = validationError ? '#d92d20' : '';
publishFilePreview.textContent = archiveFile ? (archiveFile.name + '\n服务器会先解压,再校验是否包含 ' + releaseMainExecutable) : '尚未选择压缩包';
return;
}
const entries = selectedUploadEntries();
const totalBytes = entries.reduce((sum, item) => sum + item.file.size, 0);
const skipped = allCount - entries.length;
let validationError = validateReleaseRoot(entries);
if (!validationError && publishMaxFiles > 0 && entries.length > publishMaxFiles) {
validationError = '文件数量过多:' + entries.length + ',当前上限为 ' + publishMaxFiles + ' 个';
}
if (!validationError && publishMaxRequestBytes > 0 && totalBytes > publishMaxRequestBytes) {
validationError = '发布目录过大:' + formatLogBytes(totalBytes) + ',当前上限为 ' + formatLogBytes(publishMaxRequestBytes);
}
publishBtn.disabled = Boolean(validationError);
publishFileSummary.textContent = validationError || (allCount ? ('将发布 ' + entries.length + ' 个文件,共 ' + formatLogBytes(totalBytes) + ';排除 ' + skipped + ' 个运行时/调试文件') : '请选择包含 ' + releaseMainExecutable + ' 的软件发布根目录');
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('读取渠道失败:' + friendlyErrorMessage(err), 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('保存渠道失败:' + friendlyErrorMessage(err), true);}}
async function publishVersion() {
const appId = publishAppIdInput.value.trim();
const version = publishVersionInput.value.trim();
const channel = publishChannelInput.value.trim();
const archiveMode = currentPublishMode() === 'archive';
const archiveFile = archiveMode && publishArchiveInput.files && publishArchiveInput.files.length ? publishArchiveInput.files[0] : null;
const entries = archiveMode ? [] : selectedUploadEntries();
if (!appId) { log('请选择或填写 App ID'); notify('请先选择或填写 App ID', 'info'); return; }
if (!version) { log('请输入版本号'); notify('请先填写版本号', 'info'); return; }
if (!channel) { notify('当前应用没有已启用渠道', true); return; }
if (archiveMode && !archiveFile) { notify('请选择压缩发布包', true); return; }
if (archiveFile && !isSupportedPublishArchive(archiveFile)) { notify('压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar', true); return; }
if (!archiveMode) {
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)));
if (archiveMode) {
formData.append('archive', archiveFile, archiveFile.name);
} else {
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('正在发布版本...');
notify(`正在发布 ${version},请保持页面打开`, 'info', 6000);
try {
const body = await request('/admin/publish', {
method: 'POST',
headers: { 'X-Admin-Token': currentToken },
body: formData,
});
log({ success: '发布完成', response: body });
notify(`发布成功:${version} 已完成,共 ${archiveFile ? (body.file_count || 0) : entries.length} 个文件`, 'success', 5200);
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 });
}
notify('发布失败:' + friendlyErrorMessage(err), true, 7000);
} 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('正在生成离线包,请保持页面打开', 'info', 6000);
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('离线包生成失败:' + friendlyErrorMessage(err), true, 6000);}
}
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);
notify('已设置为最新版本');
await loadVersions();
} catch (err) {
log({ error: '设置最新版本失败', err });
notify('设置最新版本失败:' + friendlyErrorMessage(err), true);
}
}
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);
notify(`版本 #${versionId} 已删除`);
await loadVersions();
} catch (err) {
log({ error: '删除版本失败', err });
notify('删除版本失败:' + friendlyErrorMessage(err), true);
}
}
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 });
notify('策略已读取', 'info');
} catch (err) { log(err); notify('读取策略失败:' + friendlyErrorMessage(err), 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('保存策略失败:' + friendlyErrorMessage(err), true); }
}
async function loadLicenses() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) {
latestLicenses = [];
licenseRows.innerHTML = '<tr><td colspan="8" class="empty">请先选择应用</td></tr>';
return;
}
try {
const data = await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
latestLicenses = rows;
licenseRows.innerHTML = rows.length ? '' : '<tr><td colspan="8" class="empty">暂无授权</td></tr>';
rows.forEach(item => {
const hasKey = Boolean(item.license_key);
const licenseHtml = hasKey
? `<span class="license-key-cell">${escapeHtml(item.license_key)}</span>`
: '<span class="license-key-cell license-key-missing">旧授权不可查看,请重新创建 License</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(item.license_id)}</td>
<td>${escapeHtml(item.customer_name)}</td>
<td>${escapeHtml(item.channel_code)}</td>
<td>${item.used_devices} / ${item.max_devices}</td>
<td>${escapeHtml(item.valid_until)}</td>
<td>${licenseHtml}</td>
<td><span class="badge ${item.status === 'active' ? 'success' : 'fail'}">${item.status === 'active' ? '有效' : '已禁用'}</span></td>
<td>
${hasKey ? `<button data-license-action="fill" data-license-id="${escapeHtml(item.license_id)}">填入配置</button>` : ''}
${hasKey ? `<button data-license-action="copy" data-license-id="${escapeHtml(item.license_id)}">复制</button>` : ''}
<button data-license-action="toggle" data-license-id="${escapeHtml(item.license_id)}" data-status="${escapeHtml(item.status)}">${item.status === 'active' ? '禁用' : '启用'}</button>
</td>`;
licenseRows.appendChild(tr);
});
if (!configLicenseKeyInput.value.trim()) {
const currentChannel = publishChannelInput.value.trim() || document.getElementById('licenseChannel').value;
const firstUsable = rows.find(item => item.status === 'active' && item.license_key && item.channel_code === currentChannel)
|| rows.find(item => item.status === 'active' && item.license_key);
if (firstUsable) {
configLicenseKeyInput.value = firstUsable.license_key;
clientConfigSummary.textContent = '已自动选用一个有效 License,可直接生成客户端配置。';
}
}
} catch (err) {
latestLicenses = [];
log(err);
notify('读取授权失败:' + friendlyErrorMessage(err), true);
}
2026-07-09 09:25:04 +00:00
}
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;configLicenseKeyInput.value=result.license_key;log(result);notify('授权已创建,已填入客户端配置生成区');await loadLicenses();}catch(err){log(err);notify('创建授权失败:' + friendlyErrorMessage(err), true);}
2026-07-09 09:25:04 +00:00
}
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('修改授权失败:' + friendlyErrorMessage(err), true);}}
async function generateClientConfig() {
const appId = currentAppId || publishAppIdInput.value.trim();
const channel = publishChannelInput.value.trim() || document.getElementById('licenseChannel').value;
const version = publishVersionInput.value.trim() || '1.0.0';
const protocol = Math.max(1, Number(publishProtocolInput.value) || 3);
const licenseKey = configLicenseKeyInput.value.trim() || document.getElementById('createdLicenseKey').value.trim();
const apiUrl = configApiBaseUrlInput.value.trim() || runtimeClientConfig.api_base_url || apiBase;
const mainExecutable = configMainExecutableInput.value.trim() || inferredMainExecutable() || runtimeClientConfig.main_executable || 'SimCAE.exe';
if (!appId) { notify('请先选择应用或填写 App ID', true); return; }
if (!channel) { notify('当前应用没有可用渠道', true); return; }
try {
const result = await request('/admin/client-config/generate', {
method: 'POST',
contentType: 'application/json',
body: JSON.stringify({
app_id: appId,
app_name: currentAppName(),
channel,
current_version: version,
client_protocol: protocol,
license_key: licenseKey,
api_base_url: apiUrl,
main_executable: mainExecutable,
}),
});
latestClientConfigText = result.json_text || JSON.stringify(result.config || {}, null, 2);
generatedClientConfig.textContent = latestClientConfigText;
copyClientConfigBtn.disabled = !latestClientConfigText;
clientConfigSummary.textContent = licenseKey
? '配置已生成;复制到客户端 bin/config/app_config.json。'
: '配置已生成但 License 为空;客户端首次启动时会提示用户输入 License。';
log({ client_config: result.config });
notify('客户端配置已生成');
} catch (err) {
log(err);
notify('生成客户端配置失败:' + friendlyErrorMessage(err), true);
}
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
async function copyClientConfig() {
const text = latestClientConfigText || generatedClientConfig.textContent.trim();
if (!text || text === '尚未生成客户端配置') { notify('请先生成客户端配置', 'info'); return; }
try {
await copyTextToClipboard(text);
notify('客户端配置已复制');
} catch (err) {
log(err);
notify('复制失败,请手动选中配置内容复制', true);
}
}
function fillLicenseIntoConfig(licenseId) {
const item = latestLicenses.find(x => x.license_id === licenseId);
if (!item || !item.license_key) {
notify('这条授权没有可查看的 License Key,请重新创建 License', true);
return;
}
configLicenseKeyInput.value = item.license_key;
document.getElementById('createdLicenseKey').value = item.license_key;
clientConfigSummary.textContent = `已选用 ${item.customer_name || item.license_id} 的 License。`;
notify('License 已填入客户端配置生成区');
}
async function copyLicenseKey(licenseId) {
const item = latestLicenses.find(x => x.license_id === licenseId);
if (!item || !item.license_key) {
notify('这条授权没有可复制的 License Key,请重新创建 License', true);
return;
}
try {
await copyTextToClipboard(item.license_key);
notify('License 已复制');
} catch (err) {
log(err);
notify('复制 License 失败,请手动选中复制', true);
}
}
2026-07-09 09:25:04 +00:00
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('读取设备列表失败:' + friendlyErrorMessage(err), 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('设备状态修改失败:' + friendlyErrorMessage(err), 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';}
function crashFileButton(reportId, fileName, label, enabled = true) {
if (!enabled) return '';
return `<button type="button" data-crash-report-id="${escapeHtml(reportId)}" data-crash-file="${escapeHtml(fileName)}">${escapeHtml(label)}</button>`;
}
async function loadCrashReports() {
try {
const data = await request('/admin/crash-report/list?limit=300');
const rows = data.list || [];
crashReportCount.textContent = rows.length ? `共 ${rows.length} 条` : '暂无崩溃报告';
crashReportRows.innerHTML = rows.length ? '' : '<tr><td colspan="12" class="empty">暂无崩溃报告</td></tr>';
rows.forEach(item => {
const totalSize = Number(item.metadata_size || 0) + Number(item.minidump_size || 0) + Number(item.attachments_size || 0);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
${crashFileButton(item.report_id, 'metadata', 'metadata')}
${crashFileButton(item.report_id, 'minidump', 'dmp')}
${crashFileButton(item.report_id, 'attachments', 'attachments', Number(item.attachments_size || 0) > 0)}
${crashFileButton(item.report_id, 'server', 'server')}
</td>
<td>${escapeHtml(item.report_id)}</td>
<td>${escapeHtml(item.app_version)}</td>
<td>${escapeHtml(item.git_commit)}</td>
<td>${escapeHtml(item.build_type)}</td>
<td>${escapeHtml(item.channel)}</td>
<td>${escapeHtml(item.exception_code || '-')}</td>
<td><span class="badge">${escapeHtml(item.symbolication_status || 'not_started')}</span></td>
<td>${escapeHtml(item.crash_time_utc || '')}</td>
<td>${escapeHtml(item.received_at_utc || '')}</td>
<td>${escapeHtml(item.remote_address || '')}</td>
<td>${formatLogBytes(totalSize)}</td>
`;
crashReportRows.appendChild(tr);
});
} catch (err) {
crashReportCount.textContent = '读取失败';
crashReportRows.innerHTML = '<tr><td colspan="12" class="empty">读取崩溃报告失败</td></tr>';
log(err);
notify('读取崩溃报告失败:' + friendlyErrorMessage(err), true);
}
}
async function downloadCrashReportFile(reportId, fileName) {
try {
const resp = await fetch(`${apiBase}/admin/crash-report/file/${encodeURIComponent(reportId)}/${encodeURIComponent(fileName)}`, {
cache: 'no-store',
headers: { 'X-Admin-Token': currentToken },
});
if (!resp.ok) {
const text = await resp.text();
let body;
try { body = JSON.parse(text); } catch { body = text; }
const error = new Error(`HTTP ${resp.status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
error.status = resp.status;
error.body = body;
throw error;
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const suffix = fileName === 'minidump' ? 'crash.dmp' : fileName === 'attachments' ? 'attachments.zip' : `${fileName}.json`;
link.href = url;
link.download = `${reportId}_${suffix}`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
notify(`已开始下载 ${fileName}`);
} catch (err) {
log(err);
notify('下载崩溃报告文件失败:' + friendlyErrorMessage(err), true);
}
}
async function deleteLog(endpoint, id, reload, label) {
if (!id || !window.confirm(`确定删除这条${label}吗?`)) return;
try {
await request(endpoint, {method:'POST', contentType:'application/json', body:JSON.stringify({id:Number(id)})});
notify(`${label}已删除`);
await reload();
} catch (err) { log(err); notify(`删除${label}失败:` + friendlyErrorMessage(err), true); }
}
async function clearLogs(endpoint, reload, label, body = {}) {
if (!window.confirm(`确定清空${label}吗?此操作不可撤销。`)) return;
try {
const result = await request(endpoint, {method:'POST', contentType:'application/json', body:JSON.stringify(body)});
notify(result.msg || `${label}已清空`);
await reload();
} catch (err) { log(err); notify(`清空${label}失败:` + friendlyErrorMessage(err), true); }
}
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 || [];
updateLogCount(downloadLogCount, rows.length, '暂无下载日志');
downloadLogRows.innerHTML = rows.length ? '' : '<tr><td colspan="9" class="empty">暂无下载日志</td></tr>';
rows.forEach(x => {
const tr = document.createElement('tr');
tr.innerHTML = `<td><button class="danger-btn" data-log-action="delete-download" data-id="${x.id}">删除</button></td><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) {
updateLogCount(downloadLogCount, 0, '读取失败');
log(err);
notify('读取下载日志失败:' + friendlyErrorMessage(err), 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="7" 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><td><button class="danger-btn" data-log-action="delete-audit" data-id="${x.id}">删除</button></td>`;auditLogRows.appendChild(tr);});}catch(err){log(err);notify('读取审计日志失败:' + friendlyErrorMessage(err), true);}}
async function loadReports() {
log('正在加载升级日志...');
try {
const data = await request('/admin/report/list');
const rows = data.list || [];
updateLogCount(reportLogCount, rows.length, '暂无升级日志');
reportRows.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>${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>
<td><button class="danger-btn" data-log-action="delete-report" data-id="${item.id}">删除</button></td>
`;
reportRows.appendChild(tr);
});
log({ reports: rows });
} catch (err) {
updateLogCount(reportLogCount, 0, '读取失败');
log({ error: '加载日志失败', err });
notify('读取升级日志失败:' + friendlyErrorMessage(err), true);
}
}
adminTokenInput.addEventListener('change', () => {
currentToken = adminTokenInput.value.trim() || currentToken;
});
configMainExecutableInput.addEventListener('input', () => {
clientMainExecutableTouched = Boolean(configMainExecutableInput.value.trim());
});
2026-07-09 09:25:04 +00:00
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();
showView('dashboard');
notify('令牌验证成功并已保存');
} catch (err) {
currentToken = previousToken;
updateTokenInput();
log(err);
notify('令牌验证失败:' + friendlyErrorMessage(err), true);
}
});
document.getElementById('openSecurityBtn').addEventListener('click', () => {
clearSecurityInputs();
showView('security');
newAdminTokenInput.focus();
});
document.getElementById('backDashboardBtn').addEventListener('click', () => {
clearSecurityInputs();
showView('dashboard');
});
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('securityLogoutBtn').addEventListener('click', logout);
document.getElementById('cancelChangeTokenBtn').addEventListener('click', clearSecurityInputs);
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
const nextToken = newAdminTokenInput.value.trim();
const confirmation = confirmAdminTokenInput.value.trim();
if (!currentToken) { notify('请先登录', true); showView('login'); return; }
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);
clearSecurityInputs();
showView('dashboard');
log(result);
notify(result.msg || '管理员令牌已更改');
} 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);
publishModeInputs.forEach(input => input.addEventListener('change', updatePublishMode));
publishFilesInput.addEventListener('change', renderSelectedDirectory);
publishArchiveInput.addEventListener('change', renderSelectedDirectory);
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
toggleReportLogBtn.addEventListener('click', () => toggleCollapsibleSection(reportLogBody, toggleReportLogBtn));
toggleDownloadLogBtn.addEventListener('click', () => toggleCollapsibleSection(downloadLogBody, toggleDownloadLogBtn));
document.getElementById('refreshReportsBtn').addEventListener('click', loadReports);
document.getElementById('clearReportsBtn').addEventListener('click', () => clearLogs('/admin/report/clear', loadReports, '升级日志'));
document.getElementById('refreshDownloadLogsBtn').addEventListener('click', loadDownloadLogs);
document.getElementById('clearDownloadLogsBtn').addEventListener('click', () => clearLogs('/admin/download-log/clear', loadDownloadLogs, '下载日志', {app_id: currentAppId || publishAppIdInput.value.trim()}));
document.getElementById('refreshAuditLogsBtn').addEventListener('click', loadAuditLogs);
document.getElementById('clearAuditLogsBtn').addEventListener('click', () => clearLogs('/admin/audit-log/clear', loadAuditLogs, '审计日志'));
document.getElementById('refreshCrashReportsBtn').addEventListener('click', loadCrashReports);
document.getElementById('refreshDevicesBtn').addEventListener('click', loadDevices);
document.getElementById('refreshLicensesBtn').addEventListener('click', loadLicenses);
document.getElementById('createLicenseBtn').addEventListener('click', createLicense);
document.getElementById('generateClientConfigBtn').addEventListener('click', generateClientConfig);
copyClientConfigBtn.addEventListener('click', copyClientConfig);
licenseRows.addEventListener('click', event => {
const b = event.target.closest('button[data-license-id]');
if (!b) return;
const action = b.dataset.licenseAction || 'toggle';
if (action === 'fill') {
fillLicenseIntoConfig(b.dataset.licenseId);
} else if (action === 'copy') {
copyLicenseKey(b.dataset.licenseId);
} else {
toggleLicense(b.dataset.licenseId, b.dataset.status);
}
});
2026-07-09 09:25:04 +00:00
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
crashReportRows.addEventListener('click', event => { const b=event.target.closest('button[data-crash-file]'); if(b) downloadCrashReportFile(b.dataset.crashReportId, b.dataset.crashFile); });
reportRows.addEventListener('click', event => { const b=event.target.closest('button[data-log-action="delete-report"]'); if(b) deleteLog('/admin/report/delete', b.dataset.id, loadReports, '升级日志'); });
downloadLogRows.addEventListener('click', event => { const b=event.target.closest('button[data-log-action="delete-download"]'); if(b) deleteLog('/admin/download-log/delete', b.dataset.id, loadDownloadLogs, '下载日志'); });
auditLogRows.addEventListener('click', event => { const b=event.target.closest('button[data-log-action="delete-audit"]'); if(b) deleteLog('/admin/audit-log/delete', b.dataset.id, loadAuditLogs, '审计日志'); });
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 || '未选择';
syncClientConfigDefaults();
2026-07-09 09:25:04 +00:00
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
publishAppIdInput.addEventListener('change', () => {
if (!currentAppId) syncClientConfigDefaults();
});
2026-07-09 09:25:04 +00:00
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 loadCrashReports();
await loadDownloadLogs();
await loadAuditLogs();
}
function renderLoggedOutState() {
versionRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示版本列表</td></tr>';
reportRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示升级日志</td></tr>';
downloadLogRows.innerHTML = '<tr><td colspan="9" class="empty">登录后显示下载日志</td></tr>';
updateLogCount(reportLogCount, 0, '登录后加载');
updateLogCount(downloadLogCount, 0, '登录后加载');
setCollapsibleState(reportLogBody, toggleReportLogBtn, true);
setCollapsibleState(downloadLogBody, toggleDownloadLogBtn, true);
auditLogRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示审计日志</td></tr>';
deviceRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示设备</td></tr>';
crashReportRows.innerHTML = '<tr><td colspan="12" class="empty">登录后显示崩溃报告</td></tr>';
crashReportCount.textContent = '登录后加载';
licenseRows.innerHTML = '<tr><td colspan="8" class="empty">登录后显示授权</td></tr>';
2026-07-09 09:25:04 +00:00
channelRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示渠道</td></tr>';
generatedClientConfig.textContent = '登录后生成客户端配置';
clientConfigSummary.textContent = '生成后复制到客户端 bin/config/app_config.json。';
latestClientConfigText = '';
copyClientConfigBtn.disabled = true;
2026-07-09 09:25:04 +00:00
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
window.addEventListener('load', () => {
apiBaseLabel.textContent = apiBase;
dashboardApiBaseLabel.textContent = apiBase;
updateTokenInput();
updatePublishMode();
renderLoggedOutState();
showView('login');
});
</script>
</body>
</html>