Files
update-server/admin.html
T

2429 lines
94 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Marsco 发布控制台</title>
<style>
:root {
--bg:#f6f7fb;
--surface:#fff;
--ink:#172033;
--muted:#667085;
--line:#dfe5ee;
--field:#fdfefe;
--brand:#3157d5;
--brand-hover:#2447bc;
--danger:#d92d20;
--success:#067647;
--warning:#9a6700;
}
* { box-sizing:border-box; }
html { background:var(--bg); }
body {
max-width:1280px;
margin:0 auto;
padding:0 24px 48px;
color:var(--ink);
background:var(--bg);
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
h1 {
margin:0 -24px 22px;
padding:24px;
color:var(--ink);
background:var(--surface);
border-bottom:1px solid var(--line);
box-shadow:0 1px 2px rgba(16,24,40,.04);
font-size:26px;
line-height:1.2;
letter-spacing:0;
}
h1::before {
content:"MARSCO UPDATE SERVICE";
display:block;
margin-bottom:6px;
color:#5d6b82;
font-size:11px;
font-weight:700;
letter-spacing:.14em;
}
h1::after {
content:" · 软件发布与升级管理";
color:var(--muted);
font-size:14px;
font-weight:400;
}
.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 {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#saveTokenBtn:hover,
#confirmChangeTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover { background:var(--brand-hover); }
.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; }
.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 {
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 {
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 {
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-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="仅显示一次;创建后请立即复制到客户端 app_config.json 的 license_key"></div>
<table><thead><tr><th>License ID</th><th>客户</th><th>渠道</th><th>设备</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="licenseRows"></tbody></table>
</div>
<div 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 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 = [];
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 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 });
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);
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){licenseRows.innerHTML='<tr><td colspan="7" class="empty">请先选择应用</td></tr>';return;}
try { const data=await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`); const rows=data.list||[]; licenseRows.innerHTML=rows.length?'':'<tr><td colspan="7" class="empty">暂无授权</td></tr>'; rows.forEach(item=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${item.license_id}</td><td>${item.customer_name}</td><td>${item.channel_code}</td><td>${item.used_devices} / ${item.max_devices}</td><td>${item.valid_until}</td><td><span class="badge ${item.status==='active'?'success':'fail'}">${item.status==='active'?'有效':'已禁用'}</span></td><td><button data-license-id="${item.license_id}" data-status="${item.status}">${item.status==='active'?'禁用':'启用'}</button></td>`;licenseRows.appendChild(tr);}); } catch(err){log(err);notify('读取授权失败:' + friendlyErrorMessage(err), true);}
}
async function createLicense(){
const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;} const body={app_id:appId,customer_name:document.getElementById('licenseCustomer').value.trim(),channel:document.getElementById('licenseChannel').value,max_devices:Number(document.getElementById('licenseMaxDevices').value),valid_until:document.getElementById('licenseValidUntil').value.trim()};
try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;log(result);notify('授权已创建,请立即复制密钥');await loadLicenses();}catch(err){log(err);notify('创建授权失败:' + friendlyErrorMessage(err), true);}
}
async function toggleLicense(id,status){try{await request('/admin/license/set-status',{method:'POST',contentType:'application/json',body:JSON.stringify({license_id:id,status:status==='active'?'disabled':'active'})});await loadLicenses();notify('授权状态已更新');}catch(err){log(err);notify('修改授权失败:' + friendlyErrorMessage(err), true);}}
async function loadDevices() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { deviceRows.innerHTML = '<tr><td colspan="6" class="empty">请先选择应用</td></tr>'; return; }
try {
const data = await request(`/admin/device/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
deviceRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">暂无登记设备</td></tr>';
rows.forEach(item => { const tr=document.createElement('tr'); tr.innerHTML=`<td>${item.device_id}</td><td><span class="badge ${item.disabled?'fail':'success'}">${item.disabled?'已禁用':'正常'}</span></td><td>${item.credential_seq}</td><td>${item.last_seen_at||''}</td><td>${item.last_ip||''}</td><td><button data-device-id="${item.device_id}" data-disabled="${item.disabled}">${item.disabled?'恢复':'禁用'}</button></td>`; deviceRows.appendChild(tr); });
} catch (err) { log(err); notify('读取设备列表失败:' + 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;
});
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);
licenseRows.addEventListener('click', event=>{const b=event.target.closest('button[data-license-id]');if(b)toggleLicense(b.dataset.licenseId,b.dataset.status);});
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
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 || '未选择';
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
versionRows.addEventListener('click', async (event) => {
const button = event.target.closest('button');
if (!button) return;
const action = button.dataset.action;
const id = button.dataset.id;
if (action === 'set-latest') {
await setLatest(id);
} else if (action === 'set-protocol') {
await setVersionProtocol(id, button.dataset.protocol);
} else if (action === 'offline-package') {
await downloadOfflinePackage(id, button.dataset.version);
} else if (action === 'delete') {
await deleteVersion(id);
}
});
async function loadConsoleData() {
await loadRuntimeConfig();
renderSelectedDirectory();
await loadApps();
await loadReports();
await loadLicenses();
await loadDevices();
await 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="7" class="empty">登录后显示授权</td></tr>';
channelRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示渠道</td></tr>';
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
window.addEventListener('load', () => {
apiBaseLabel.textContent = apiBase;
dashboardApiBaseLabel.textContent = apiBase;
updateTokenInput();
updatePublishMode();
renderLoggedOutState();
showView('login');
});
</script>
</body>
</html>