2429 lines
94 KiB
HTML
2429 lines
94 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>Marsco 发布控制台</title>
|
||
<style>
|
||
:root {
|
||
--bg:#f6f7fb;
|
||
--surface:#fff;
|
||
--ink:#172033;
|
||
--muted:#667085;
|
||
--line:#dfe5ee;
|
||
--field:#fdfefe;
|
||
--brand:#3157d5;
|
||
--brand-hover:#2447bc;
|
||
--danger:#d92d20;
|
||
--success:#067647;
|
||
--warning:#9a6700;
|
||
}
|
||
* { box-sizing:border-box; }
|
||
html { background:var(--bg); }
|
||
body {
|
||
max-width:1280px;
|
||
margin:0 auto;
|
||
padding:0 24px 48px;
|
||
color:var(--ink);
|
||
background:var(--bg);
|
||
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||
}
|
||
h1 {
|
||
margin:0 -24px 22px;
|
||
padding:24px;
|
||
color:var(--ink);
|
||
background:var(--surface);
|
||
border-bottom:1px solid var(--line);
|
||
box-shadow:0 1px 2px rgba(16,24,40,.04);
|
||
font-size:26px;
|
||
line-height:1.2;
|
||
letter-spacing:0;
|
||
}
|
||
h1::before {
|
||
content:"MARSCO UPDATE SERVICE";
|
||
display:block;
|
||
margin-bottom:6px;
|
||
color:#5d6b82;
|
||
font-size:11px;
|
||
font-weight:700;
|
||
letter-spacing:.14em;
|
||
}
|
||
h1::after {
|
||
content:" · 软件发布与升级管理";
|
||
color:var(--muted);
|
||
font-size:14px;
|
||
font-weight:400;
|
||
}
|
||
.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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[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>
|