Files
ecs-controller/template.html

9366 lines
401 KiB
HTML
Raw Permalink 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.0">
<title>ECS 控制台</title>
<meta name="description" content="阿里云 ECS 流量监控与实例管理控制台,支持实例状态查看、自动关机、账号管理、系统设置与日志审计。">
<link id="app-favicon" rel="icon" type="image/png" href="icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="static/tailwind-compiled.css?v=__ASSET_VERSION__">
<script>
// 自动跟随系统主题
(function() {
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<style>
:root {
--canvas: #edf4f8;
--panel: #f9fcfe;
--panel-strong: #e5eff5;
--line: #d3e0e8;
--text: #102a43;
--muted: #5d7487;
--accent: #0f7894;
--accent-strong: #0b6078;
--accent-light: #dff3f7;
--danger: #d65d6a;
--warning: #c88a35;
--success: #0b9687;
--danger-bg: #fff2f4;
--danger-border: #f1c2ca;
--danger-text: #b23a50;
--warning-bg: #fff8ec;
--warning-border: #efd9a9;
--warning-text: #9a661f;
--success-bg: #eafaf6;
--success-border: #b9e4d9;
--success-text: #087d6f;
--status-running-bg: #eafaf6;
--status-running-text: #087d6f;
--status-stopped-bg: #fff2f4;
--status-stopped-text: #b23a50;
--status-transition-bg: #fff8ec;
--status-transition-text: #9a661f;
--status-unknown-bg: #eef3f6;
--status-unknown-text: #5d7487;
--hover-bg: #eef5f8;
--control-bg: #f4f8fa;
--control-hover-bg: #eaf2f6;
--switch-on-bg: #0f7894;
--accent-contrast: #ffffff;
--input-bg: #f8fbfd;
--glass-input-border: #c5d6e1;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0b1724;
--panel: #12283b;
--panel-strong: #19384d;
--line: #2b4a60;
--text: #e8f2f7;
--muted: #91abba;
--accent: #5cc4d8;
--accent-strong: #38a8c1;
--accent-light: #163f50;
--danger: #f07b88;
--warning: #e4b05e;
--success: #62d1bf;
--danger-bg: #3b2932;
--danger-border: #704650;
--danger-text: #f2b5c0;
--warning-bg: #3b3227;
--warning-border: #6d5a3c;
--warning-text: #edcf98;
--success-bg: #183d3c;
--success-border: #2b7068;
--success-text: #93dfd1;
--input-bg: #102235;
--glass-input-border: #36586e;
--status-running-bg: #183d3c;
--status-running-text: #93dfd1;
--status-stopped-bg: #3b2932;
--status-stopped-text: #f2b5c0;
--status-transition-bg: #3b3227;
--status-transition-text: #edcf98;
--status-unknown-bg: #263c4d;
--status-unknown-text: #a5bac7;
--hover-bg: #1b3a50;
--control-bg: #172f43;
--control-hover-bg: #1e3c50;
--switch-on-bg: #3f9eb0;
--accent-contrast: #082631;
}
}
[data-theme="light"] {
--canvas: #edf4f8;
--panel: #f9fcfe;
--panel-strong: #e5eff5;
--line: #d3e0e8;
--text: #102a43;
--muted: #5d7487;
--accent: #0f7894;
--accent-strong: #0b6078;
--accent-light: #dff3f7;
--danger: #d65d6a;
--warning: #c88a35;
--success: #0b9687;
--danger-bg: #fff2f4;
--danger-border: #f1c2ca;
--danger-text: #b23a50;
--warning-bg: #fff8ec;
--warning-border: #efd9a9;
--warning-text: #9a661f;
--success-bg: #eafaf6;
--success-border: #b9e4d9;
--success-text: #087d6f;
--status-running-bg: #eafaf6;
--status-running-text: #087d6f;
--status-stopped-bg: #fff2f4;
--status-stopped-text: #b23a50;
--status-transition-bg: #fff8ec;
--status-transition-text: #9a661f;
--status-unknown-bg: #eef3f6;
--status-unknown-text: #5d7487;
--hover-bg: #eef5f8;
--control-bg: #f4f8fa;
--control-hover-bg: #eaf2f6;
--switch-on-bg: #0f7894;
--accent-contrast: #ffffff;
--input-bg: #f8fbfd;
--glass-input-border: #c5d6e1;
}
[data-theme="dark"] {
--canvas: #0b1724;
--panel: #12283b;
--panel-strong: #19384d;
--line: #2b4a60;
--text: #e8f2f7;
--muted: #91abba;
--accent: #5cc4d8;
--accent-strong: #38a8c1;
--accent-light: #163f50;
--danger: #f07b88;
--warning: #e4b05e;
--success: #62d1bf;
--danger-bg: #3b2932;
--danger-border: #704650;
--danger-text: #f2b5c0;
--warning-bg: #3b3227;
--warning-border: #6d5a3c;
--warning-text: #edcf98;
--success-bg: #183d3c;
--success-border: #2b7068;
--success-text: #93dfd1;
--input-bg: #102235;
--glass-input-border: #36586e;
--status-running-bg: #183d3c;
--status-running-text: #93dfd1;
--status-stopped-bg: #3b2932;
--status-stopped-text: #f2b5c0;
--status-transition-bg: #3b3227;
--status-transition-text: #edcf98;
--status-unknown-bg: #263c4d;
--status-unknown-text: #a5bac7;
--hover-bg: #1b3a50;
--control-bg: #172f43;
--control-hover-bg: #1e3c50;
--switch-on-bg: #3f9eb0;
--accent-contrast: #082631;
}
/* 暗色模式 Tailwind 类覆盖 */
[data-theme="dark"] .bg-white\/65 {
background: rgba(30, 41, 59, 0.65);
}
[data-theme="dark"] .border-slate-200\/70 {
border-color: rgba(51, 65, 85, 0.7);
}
[data-theme="dark"] .bg-white\/70 {
background: rgba(30, 41, 59, 0.7);
}
[data-theme="dark"] [class~="text-[#6f7787]"] {
color: var(--muted) !important;
}
[data-theme="dark"] [class~="text-[#1f2328]"] {
color: var(--text) !important;
}
[data-theme="dark"] [class~="text-[#dc2626]"] {
color: var(--danger-text) !important;
}
[data-theme="dark"] [class~="text-[#d97706]"] {
color: var(--warning-text) !important;
}
[data-theme="dark"] [class~="bg-amber-50"] {
background: var(--warning-bg) !important;
}
[data-theme="dark"] [class~="text-amber-700"] {
color: var(--warning-text) !important;
}
@keyframes panel-fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes soft-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-rise {
from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 8% -10%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 28%),
radial-gradient(circle at 94% 0%, color-mix(in srgb, var(--panel-strong) 70%, transparent), transparent 24%),
var(--canvas);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
-webkit-font-smoothing: antialiased;
}
[v-cloak] {
display: none !important;
}
.content-skeleton__line,
.content-skeleton__block {
border-radius: 999px;
background: linear-gradient(90deg, color-mix(in srgb, var(--panel-strong) 80%, var(--panel)) 0%, color-mix(in srgb, var(--accent-light) 68%, var(--panel-strong)) 50%, color-mix(in srgb, var(--panel-strong) 80%, var(--panel)) 100%);
background-size: 200% 100%;
animation: boot-sheen 1.35s linear infinite;
}
.content-skeleton {
display: grid;
gap: 16px;
}
.content-skeleton__head {
display: grid;
gap: 9px;
}
.content-skeleton__line--short { width: 84px; height: 9px; }
.content-skeleton__line--title { width: min(210px, 68%); height: 22px; }
.content-skeleton__line--copy { width: min(390px, 88%); height: 10px; }
.content-skeleton__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.content-skeleton__block {
min-height: 148px;
border-radius: 16px;
}
@keyframes boot-sheen {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
.content-skeleton__line,
.content-skeleton__block {
transition: none;
animation: none;
}
}
.glass-panel {
background: var(--panel);
border: 1px solid var(--line);
box-shadow: 0 12px 30px color-mix(in srgb, var(--canvas) 55%, transparent);
transition: box-shadow .2s ease, border-color .2s ease;
}
.glass-input,
.glass-textarea {
width: 100%;
border: 1px solid var(--glass-input-border);
background: var(--input-bg);
color: var(--text);
border-radius: 10px;
padding: 0.65rem 0.85rem;
font-size: 0.875rem;
outline: none;
transition: border-color .15s ease, box-shadow .15s ease;
}
.glass-input:focus,
.glass-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
.glass-textarea {
min-height: 108px;
resize: vertical;
}
.glass-input:hover,
.glass-textarea:hover {
border-color: var(--line);
}
input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.glass-select {
width: 100%;
min-width: 0;
}
.select-shell {
position: relative;
width: 100%;
min-width: 0;
}
.select-shell.glass-select {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.select-shell.open {
z-index: 30;
}
.select-trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0.6rem 0.85rem;
border-radius: 10px;
border: 1px solid var(--glass-input-border);
background: var(--input-bg);
color: var(--text);
transition: border-color .15s ease, box-shadow .15s ease;
}
.select-trigger:hover {
border-color: var(--line);
}
.select-shell.open .select-trigger,
.select-trigger:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
.select-trigger:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.select-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
font-size: 0.92rem;
font-weight: 600;
}
.select-value.is-placeholder {
color: #8a94a6;
font-weight: 500;
}
.select-chevron {
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: var(--hover-bg, #f3f4f6);
color: var(--muted);
flex-shrink: 0;
transition: transform .15s ease;
}
.select-shell.open .select-chevron {
transform: rotate(180deg);
}
.select-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
padding: 4px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.account-modal .select-menu {
max-height: 210px;
border-radius: 10px;
}
.account-modal .select-option {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.875rem;
}
.select-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0.55rem 0.7rem;
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
font-weight: 500;
text-align: left;
transition: background .12s ease;
}
.select-option:hover {
background: var(--hover-bg, #f3f4f6);
}
.select-option.selected {
background: var(--accent-light);
color: var(--accent);
}
.select-check {
width: 1.15rem;
height: 1.15rem;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
flex-shrink: 0;
}
.select-pop-enter-active,
.select-pop-leave-active {
transition: opacity .12s ease, transform .12s ease;
}
.select-pop-enter-from,
.select-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.toast-stack {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 80;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: min(380px, calc(100vw - 32px));
}
.toast-card {
position: relative;
isolation: isolate;
overflow: hidden;
width: min(100%, 360px);
border-radius: 14px;
padding: 11px 15px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line));
background:
radial-gradient(circle at 98% 0%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 42%),
color-mix(in srgb, var(--panel) 98%, var(--accent) 2%);
box-shadow:
0 18px 42px color-mix(in srgb, var(--text) 16%, transparent),
0 4px 12px color-mix(in srgb, var(--accent) 10%, transparent);
backdrop-filter: blur(14px);
}
.toast-card::before {
position: absolute;
top: 0;
right: 18px;
left: 18px;
height: 2px;
border-radius: 0 0 999px 999px;
background: var(--accent);
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 32%, transparent);
content: "";
}
.toast-card::after {
position: absolute;
right: -64px;
bottom: -78px;
width: 180px;
height: 120px;
border: 1px solid color-mix(in srgb, var(--accent) 16%, transparent);
border-radius: 50%;
content: "";
transform: rotate(-12deg);
pointer-events: none;
z-index: -1;
}
.toast-card__status {
display: inline-flex;
align-items: center;
gap: 8px;
}
.toast-status-dot {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent);
}
.toast-title {
color: var(--accent);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.13em;
}
.toast-message {
position: relative;
z-index: 1;
margin-top: 7px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.5;
color: var(--text);
}
.toast-card.success .toast-status-dot {
background: var(--success);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 12%, transparent);
}
.toast-card.success .toast-title {
color: var(--success);
}
.toast-card.success::before {
background: var(--success);
box-shadow: 0 2px 8px color-mix(in srgb, var(--success) 32%, transparent);
}
.toast-card.error .toast-status-dot {
background: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 12%, transparent);
}
.toast-card.error .toast-title {
color: var(--danger);
}
.toast-card.error::before {
background: var(--danger);
box-shadow: 0 2px 8px color-mix(in srgb, var(--danger) 32%, transparent);
}
.toast-enter-active,
.toast-leave-active {
transition: opacity .18s ease, transform .18s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.3);
animation: soft-fade-in .15s ease both;
}
.dialog-card {
width: min(520px, 100%);
border-radius: 16px;
padding: 24px;
animation: modal-rise .2s ease both;
}
.dialog-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.menu-button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 64px;
padding: 11px 13px;
border-radius: 16px;
border: 1px solid transparent;
background: color-mix(in srgb, var(--panel) 58%, transparent);
color: var(--muted);
text-align: left;
transition: background .18s ease, border-color .18s ease, color .18s ease, transform .18s ease;
}
.menu-button:hover {
border-color: var(--line);
background: color-mix(in srgb, var(--panel) 84%, transparent);
color: var(--text);
transform: translateY(-1px);
}
.menu-button:focus-visible,
.settings-nav__button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu-button.active {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
background: var(--accent-light);
color: var(--text);
box-shadow: inset 0 3px 0 var(--accent);
}
.menu-index {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 800;
}
.menu-button.active .menu-index {
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
background: var(--accent);
color: #fff;
}
.menu-label {
display: block;
font-size: 0.86rem;
font-weight: 800;
line-height: 1.2;
}
.menu-hint {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 0.7rem;
line-height: 1.4;
}
.metric-card {
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow .2s ease, border-color .2s ease;
animation: panel-fade-up .3s ease both;
}
.metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
border-color: var(--line);
}
.section-title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-running {
background: var(--status-running-bg, #ecfdf5);
color: var(--status-running-text, #059669);
}
.status-stopped,
.status-released {
background: var(--status-stopped-bg, #fef2f2);
color: var(--status-stopped-text, #dc2626);
}
.status-transition {
background: var(--status-transition-bg, #fffbeb);
color: var(--status-transition-text, #d97706);
}
.status-error {
background: #fef2f2;
color: #b91c1c;
}
.status-unknown {
background: var(--status-unknown-bg, #f3f4f6);
color: var(--status-unknown-text, #6b7280);
}
.table-shell {
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
overflow: hidden;
animation: panel-fade-up .3s ease both;
}
.table-scroll {
overflow-x: auto;
}
.table-scroll table {
width: 100%;
}
.progress-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--hover-bg, #f3f4f6);
overflow: hidden;
border: none !important;
box-shadow: none !important;
}
.progress-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width .4s ease;
}
.progress-fill.warning {
background: var(--danger);
}
.subtle-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.ghost-button,
.solid-button,
.danger-button,
.success-button,
.warning-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
border-radius: 8px;
padding: 0.5rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
transform: translateY(0);
cursor: pointer;
user-select: none;
transition: background .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease, transform .12s ease, opacity .12s ease;
}
.ghost-button:hover,
.solid-button:hover,
.danger-button:hover,
.success-button:hover,
.warning-button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
.ghost-button:active,
.solid-button:active,
.danger-button:active,
.success-button:active,
.warning-button:active {
transform: translateY(0) scale(0.98);
box-shadow: none;
}
.ghost-button:focus-visible,
.solid-button:focus-visible,
.danger-button:focus-visible,
.success-button:focus-visible,
.warning-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ghost-button:disabled,
.solid-button:disabled,
.danger-button:disabled,
.success-button:disabled,
.warning-button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
box-shadow: none;
}
.ghost-button {
background: var(--hover-bg, #f3f4f6);
border: 1px solid var(--line);
color: var(--text);
}
.ghost-button:hover {
background: var(--line);
opacity: 1;
}
.solid-button {
background: var(--accent);
color: var(--accent-contrast);
border: 1px solid color-mix(in srgb, var(--accent-strong) 65%, var(--line));
}
.solid-button:hover {
background: var(--accent-strong);
}
.success-button {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.success-button:hover {
background: color-mix(in srgb, var(--success) 18%, var(--panel));
border-color: color-mix(in srgb, var(--success) 42%, var(--line));
}
.warning-button {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
}
.warning-button:hover {
background: color-mix(in srgb, var(--warning) 18%, var(--panel));
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
}
.danger-button {
background: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger-text);
}
.danger-button:hover {
background: color-mix(in srgb, var(--danger) 18%, var(--panel));
border-color: color-mix(in srgb, var(--danger) 42%, var(--line));
}
.sync-button {
min-width: 78px;
border-radius: 8px;
}
.sync-button.pending {
background: color-mix(in srgb, var(--panel-strong) 60%, var(--panel));
border-color: var(--line);
color: var(--muted);
}
.sync-button.synced {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
.sync-button.error {
background: var(--danger-bg);
border-color: var(--danger-border);
color: var(--danger-text);
}
.sync-note {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--muted);
}
.sync-note.error {
color: var(--danger-text);
}
.sync-note.success {
color: var(--success-text);
}
.test-result-card {
margin-top: 18px;
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--panel-strong) 72%, var(--panel));
color: var(--text);
display: grid;
gap: 10px;
}
.test-result-card.warning {
border-color: var(--danger-border);
background: color-mix(in srgb, var(--danger-bg) 78%, var(--panel));
}
.test-result-card.success {
border-color: var(--success-border);
background: color-mix(in srgb, var(--success-bg) 78%, var(--panel));
}
.test-result-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
font-size: 13px;
}
.test-result-line strong {
color: var(--text);
}
.test-result-title {
color: var(--text);
}
.test-result-message {
color: var(--muted);
}
.test-result-line > span {
color: var(--muted);
text-align: right;
overflow-wrap: anywhere;
}
.test-result-line > span.warning-text {
color: var(--danger-text);
}
.test-result-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
.test-result-badge.warning {
background: var(--danger-bg);
color: var(--danger-text);
border-color: var(--danger-border);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--panel);
font-size: 0.75rem;
color: var(--muted);
}
.switch-row {
cursor: pointer;
border-color: var(--line) !important;
background: var(--control-bg) !important;
color: var(--text);
transition: background .16s ease, border-color .16s ease;
}
.switch-row:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line)) !important;
background: var(--control-hover-bg) !important;
}
.settings-brand-card {
border-color: var(--line) !important;
background: color-mix(in srgb, var(--panel-strong) 68%, var(--panel)) !important;
}
.settings-brand-card > label,
.settings-grid > .metric-card label {
color: var(--muted) !important;
}
.switch-control {
position: relative;
display: inline-flex;
width: 44px;
height: 24px;
flex: 0 0 auto;
}
.switch-control input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.switch-slider {
position: absolute;
inset: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 82%, var(--panel));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 8%, transparent);
transition: background .16s ease, box-shadow .16s ease;
pointer-events: none;
}
.switch-slider::after {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform .15s ease;
}
.switch-control input:checked + .switch-slider {
background: var(--switch-on-bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--switch-on-bg) 68%, #000 32%), 0 0 0 3px color-mix(in srgb, var(--switch-on-bg) 12%, transparent);
}
.switch-control input:checked + .switch-slider::after {
transform: translateX(20px);
}
.switch-control input:focus-visible + .switch-slider {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.schedule-card {
border: 1px solid var(--line);
border-radius: 24px;
background: color-mix(in srgb, var(--panel) 82%, transparent);
padding: 1rem;
}
.schedule-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.schedule-card__hint {
margin-top: 0.2rem;
color: var(--muted);
font-size: 0.75rem;
line-height: 1.6;
}
.schedule-option {
border: 1px solid var(--line);
border-radius: 20px;
background: color-mix(in srgb, var(--panel-strong) 78%, transparent);
padding: 1rem;
}
.schedule-option__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.schedule-options {
overflow: hidden;
}
/* Let the open time menu escape the animated wrapper instead of being clipped. */
.schedule-options:has(.time-picker.open) {
overflow: visible;
}
.schedule-option:has(.time-picker.open) {
position: relative;
z-index: 90;
}
.schedule-options-enter-active,
.schedule-options-leave-active {
max-height: 360px;
opacity: 1;
transform: translateY(0);
transition: max-height .28s cubic-bezier(.22, .61, .36, 1),
margin-top .28s cubic-bezier(.22, .61, .36, 1),
opacity .2s ease,
transform .28s cubic-bezier(.22, .61, .36, 1);
}
.schedule-options-enter-from,
.schedule-options-leave-to {
max-height: 0;
margin-top: 0 !important;
opacity: 0;
transform: translateY(-8px);
}
.time-picker {
position: relative;
width: 100%;
}
.time-picker__trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid var(--glass-input-border);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
padding: 0.65rem 0.85rem;
font-size: 0.875rem;
font-weight: 600;
text-align: left;
transition: border-color .15s ease, box-shadow .15s ease;
}
.time-picker.open .time-picker__trigger,
.time-picker__trigger:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
.time-picker__trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.time-picker__value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.time-picker__placeholder {
color: var(--muted);
font-family: inherit;
font-weight: 500;
}
.time-picker__icon {
width: 1.35rem;
height: 1.35rem;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
flex: 0 0 auto;
}
.time-picker__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 80;
width: min(280px, calc(100vw - 40px));
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.18);
padding: 0.65rem;
}
.time-picker.drop-up .time-picker__panel {
top: auto;
bottom: calc(100% + 8px);
}
.time-picker__columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.time-picker__column {
max-height: min(180px, 32vh);
overflow-y: auto;
border-radius: 12px;
background: color-mix(in srgb, var(--panel-strong) 54%, transparent);
padding: 0.25rem;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
}
.time-picker__column::-webkit-scrollbar {
width: 6px;
}
.time-picker__column::-webkit-scrollbar-track {
background: transparent;
}
.time-picker__column::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--muted) 45%, transparent);
}
.time-picker__item {
width: 100%;
border-radius: 10px;
color: var(--text);
padding: 0.48rem 0.3rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.875rem;
font-weight: 700;
text-align: center;
}
.time-picker__item:hover {
background: var(--hover-bg, #f3f4f6);
}
.time-picker__item.active {
background: var(--accent);
color: #fff;
}
.time-picker__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.65rem;
}
.time-picker__action {
border-radius: 10px;
padding: 0.45rem 0.7rem;
color: var(--accent);
font-size: 0.8rem;
font-weight: 700;
transition: background .16s ease, color .16s ease, transform .12s ease;
}
.time-picker__action:hover {
background: var(--accent-light);
transform: translateY(-1px);
}
.time-picker__action:active {
transform: translateY(0) scale(0.98);
}
.time-picker__confirm {
background: var(--accent);
color: #fff;
}
.time-picker__confirm:hover {
background: var(--accent-strong);
color: #fff;
}
.account-row {
display: grid;
grid-template-columns: minmax(180px, 1.45fr) minmax(120px, 0.9fr) minmax(110px, 0.8fr) 180px 220px 180px;
gap: 0.85rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
align-items: center;
transition: background .1s ease;
}
.account-row:hover {
background: var(--panel);
}
.account-row:last-child {
border-bottom: 0;
}
.account-cell label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
text-transform: uppercase;
}
.mobile-stack {
display: none;
}
.summary-table-head {
background: var(--panel);
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.summary-cell {
min-width: 0;
}
.center-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
}
.summary-primary {
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-secondary {
margin-top: 0.2rem;
font-size: 0.75rem;
color: var(--muted);
}
.row-actions {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
align-items: center;
width: 100%;
max-width: none;
margin: 0 auto;
}
.row-actions > button {
white-space: nowrap;
}
.account-management-row {
grid-template-columns: minmax(140px, 1.2fr) minmax(90px, 0.75fr) minmax(80px, 0.65fr) minmax(145px, 1fr) minmax(135px, 0.9fr) minmax(190px, 1.2fr);
}
.account-management-row .row-actions {
justify-content: center;
}
.account-modal-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.3);
animation: soft-fade-in .15s ease both;
}
.account-modal {
width: min(760px, 100%);
border-radius: 16px;
padding: 24px;
animation: modal-rise .2s ease both;
}
.account-modal__body {
min-height: 0;
overflow-y: auto;
margin-top: 20px;
padding-right: 4px;
scrollbar-gutter: stable;
}
.account-modal__body .modal-form-grid {
margin-top: 0;
}
.modal-form-grid {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.modal-field-full {
grid-column: 1 / -1;
}
.create-modal .modal-field-full {
grid-column: auto;
}
.create-modal {
width: min(920px, 100%);
max-height: min(86vh, 860px);
overflow: auto;
}
.summary-block {
margin-top: 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
padding: 16px;
}
.summary-block h4 {
font-size: 0.85rem;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
}
.summary-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
font-size: 0.875rem;
}
.summary-list div {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
}
.summary-list strong {
color: var(--text);
text-align: right;
overflow-wrap: anywhere;
}
.warning-list {
margin-top: 12px;
display: grid;
gap: 6px;
color: #92400e;
font-size: 0.85rem;
}
.confirm-check {
margin-top: 16px;
display: flex;
gap: 10px;
align-items: center;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
background: var(--panel);
font-size: 0.875rem;
color: #374151;
}
.field-label {
display: block;
margin-bottom: 6px;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.settings-save-float {
position: fixed;
right: clamp(18px, 4vw, 44px);
bottom: max(28px, env(safe-area-inset-bottom));
z-index: 70;
display: flex;
align-items: center;
gap: 10px;
max-width: min(400px, calc(100vw - 32px));
padding: 8px 8px 8px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line));
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, var(--accent) 8%);
box-shadow:
0 18px 42px color-mix(in srgb, var(--canvas) 68%, transparent),
0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent);
backdrop-filter: blur(18px) saturate(135%);
isolation: isolate;
}
.settings-save-float__hint {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 8px;
color: var(--text);
font-size: 0.78rem;
font-weight: 650;
line-height: 1.25;
letter-spacing: 0.01em;
white-space: nowrap;
}
.settings-save-float__hint::before {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--warning);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--warning) 16%, transparent);
content: "";
}
.settings-save-float > .solid-button {
min-height: 38px;
flex: 0 0 auto;
padding: 0.52rem 0.82rem;
border-radius: 12px;
font-size: 0.76rem;
white-space: nowrap;
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 24%, transparent), 0 6px 14px color-mix(in srgb, var(--accent-strong) 22%, transparent);
}
.settings-save-enter-active,
.settings-save-leave-active {
transition: opacity .2s ease, transform .24s cubic-bezier(.22, .61, .36, 1);
}
.settings-save-enter-from,
.settings-save-leave-to {
opacity: 0;
transform: translateY(10px) scale(.98);
}
.account-card-grid {
display: grid;
gap: 10px;
margin-top: 14px;
color: var(--muted);
font-size: 0.8125rem;
line-height: 1.45;
}
.account-card-grid__meta,
.account-card-grid__metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.account-card-grid__label {
flex: 0 0 auto;
color: var(--muted);
}
.account-card-grid__value {
min-width: 0;
color: var(--text);
font-weight: 650;
text-align: right;
overflow-wrap: anywhere;
}
.account-card-grid__metric .account-card-grid__value {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.account-card-grid .progress-track {
margin-top: -3px;
}
.account-usage {
width: 100%;
max-width: 132px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
}
.account-usage__meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 0.8rem;
color: var(--muted);
}
.account-usage__meta.single {
justify-content: center;
width: 100%;
text-align: center;
}
.account-usage__bar {
margin-top: 6px;
width: 100%;
min-width: 0;
max-width: none;
}
.account-billing {
width: 100%;
max-width: 180px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.account-billing__line {
font-size: 0.8rem;
color: var(--muted);
line-height: 1.4;
}
.account-billing__value {
font-weight: 600;
color: var(--text);
}
.account-billing__hint {
font-size: 0.75rem;
color: #9ca3af;
}
.account-billing__error {
font-size: 0.75rem;
color: var(--danger);
line-height: 1.4;
}
.account-billing__details-button {
align-self: center;
margin-top: 2px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--accent-light) 76%, transparent);
color: var(--accent-strong);
padding: 0.28rem 0.6rem;
font-size: 0.7rem;
font-weight: 800;
transition: background .15s ease, color .15s ease, transform .15s ease;
}
.account-billing__details-button:hover {
background: var(--accent);
color: #fff;
transform: translateY(-1px);
}
.billing-modal {
width: min(940px, 100%);
height: min(760px, calc(100vh - 32px));
max-height: min(760px, calc(100vh - 32px));
display: flex;
flex-direction: column;
overflow: hidden;
}
.billing-details-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: color-mix(in srgb, var(--panel-strong) 60%, transparent);
padding: 14px 16px;
}
.billing-details-summary__label {
color: var(--muted);
font-size: 0.75rem;
}
.billing-details-summary__value {
margin-top: 3px;
color: var(--text);
font-size: 1.25rem;
font-weight: 850;
}
.billing-details-summary__meta {
color: var(--muted);
font-size: 0.75rem;
text-align: right;
}
.billing-details-title__amount {
margin-left: 0.45em;
color: var(--accent-strong);
font-variant-numeric: tabular-nums;
}
.billing-content-stage {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.billing-content-view {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.billing-history-list {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: 9px;
min-height: 0;
overflow: auto;
margin-top: 14px;
padding: 1px;
}
.billing-history-row {
display: grid;
grid-template-columns: minmax(132px, .75fr) minmax(0, 1.5fr) auto auto auto;
align-items: center;
gap: 14px;
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
background: color-mix(in srgb, var(--panel-strong) 54%, transparent);
color: var(--text);
padding: 12px 14px;
text-align: left;
transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.billing-history-row:hover {
border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
background: color-mix(in srgb, var(--accent-light) 34%, var(--panel-strong));
transform: translateY(-1px);
}
@keyframes billing-row-enter {
from {
transform: translateY(4px);
}
to {
transform: translateY(0);
}
}
.billing-history-row,
.billing-details-table tbody tr {
animation: billing-row-enter .18s cubic-bezier(.2, .75, .25, 1) both;
animation-delay: var(--billing-enter-delay, 0ms);
will-change: transform;
}
.billing-history-row__date,
.billing-history-row__products {
min-width: 0;
}
.billing-history-row__date-label {
color: var(--text);
font-size: .82rem;
font-weight: 800;
}
.billing-history-row__date-value,
.billing-history-row__products,
.billing-history-row__count {
margin-top: 3px;
color: var(--muted);
font-size: .7rem;
line-height: 1.45;
}
.billing-history-row__products {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.billing-history-row__count {
white-space: nowrap;
}
.billing-history-row__amount {
color: var(--text);
font-size: .9rem;
font-weight: 850;
white-space: nowrap;
}
.billing-history-row__arrow {
color: var(--accent-strong);
font-size: .72rem;
font-weight: 800;
white-space: nowrap;
}
.billing-details-back {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 5px;
border: 0;
background: transparent;
color: var(--accent-strong);
font-size: .72rem;
font-weight: 800;
padding: 0;
}
.billing-details-back:hover {
color: var(--accent);
}
.billing-details-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
margin-top: 14px;
border: 1px solid var(--line);
border-radius: 16px;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 62%, var(--panel-strong)) color-mix(in srgb, var(--panel-strong) 88%, var(--panel));
scrollbar-gutter: stable;
}
.billing-details-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.billing-details-scroll::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--panel-strong) 88%, var(--panel));
border-left: 1px solid color-mix(in srgb, var(--line) 74%, transparent);
}
.billing-details-scroll::-webkit-scrollbar-thumb {
min-height: 44px;
border: 3px solid transparent;
border-radius: 999px;
background: color-mix(in srgb, var(--muted) 62%, var(--panel-strong));
background-clip: padding-box;
transition: background-color .15s ease;
}
.billing-details-scroll::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--accent) 66%, var(--muted));
}
.billing-details-scroll::-webkit-scrollbar-thumb:active {
background-color: var(--accent);
}
.billing-details-table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
font-size: 0.8rem;
}
.billing-details-table th,
.billing-details-table td {
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
padding: 11px 13px;
text-align: left;
vertical-align: top;
}
.billing-details-table th {
position: sticky;
top: 0;
z-index: 1;
background: var(--panel-strong);
color: var(--muted);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: .04em;
}
.billing-details-table tr:last-child td {
border-bottom: 0;
}
.billing-details-table__product {
color: var(--text);
font-weight: 750;
}
.billing-details-table__item {
margin-top: 4px;
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 750;
}
.billing-details-table__code,
.billing-details-table__instance {
margin-top: 3px;
color: var(--muted);
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.billing-details-table__amount {
color: var(--text);
font-weight: 800;
white-space: nowrap;
}
.billing-details-table__usage {
min-width: 230px;
}
.billing-details-table__usage-main {
color: var(--text);
font-weight: 750;
}
.billing-details-table__usage-current,
.billing-details-table__usage-billing,
.billing-details-table__usage-config {
margin-top: 4px;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.billing-details-table__usage-current {
color: var(--accent-strong);
font-weight: 700;
}
.billing-details-empty {
margin-top: 14px;
border: 1px dashed var(--line);
border-radius: 16px;
color: var(--muted);
padding: 32px 20px;
text-align: center;
}
@media (max-width: 600px) {
.billing-history-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 12px;
padding: 12px;
}
.billing-history-row__products {
grid-column: 1 / -1;
grid-row: 2;
}
.billing-history-row__count {
grid-column: 1;
grid-row: 3;
}
.billing-history-row__amount {
grid-column: 2;
grid-row: 3;
}
.billing-history-row__arrow {
display: none;
}
.billing-details-summary {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.billing-details-summary__meta {
text-align: left;
}
/* Turn the wide desktop table into readable bill entries on phones. */
.billing-details-scroll {
overflow-x: hidden;
border-radius: 14px;
scrollbar-gutter: auto;
}
.billing-details-table,
.billing-details-table tbody {
display: block;
min-width: 0;
width: 100%;
}
.billing-details-table thead {
display: none;
}
.billing-details-table tr {
position: relative;
display: block;
min-width: 0;
padding: 12px;
}
.billing-details-table th,
.billing-details-table td {
display: block;
min-width: 0;
border: 0;
padding: 0;
}
.billing-details-table tr:not(:last-child) {
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.billing-details-table td:first-child {
padding-right: 66px;
}
.billing-details-table__amount {
position: absolute;
top: 12px;
right: 12px;
color: var(--accent-strong);
font-size: .84rem;
}
.billing-details-table__usage {
min-width: 0;
margin-top: 6px;
padding-top: 0 !important;
border-top: 0 !important;
}
.billing-details-table__product {
font-size: .8rem;
}
.billing-details-table__item {
margin-top: 3px;
}
.billing-details-table__usage-main {
font-size: .78rem;
line-height: 1.45;
}
.billing-details-table__code,
.billing-details-table__instance,
.billing-details-table__usage-current,
.billing-details-table__usage-billing,
.billing-details-table__usage-config {
line-height: 1.42;
}
}
@media (prefers-reduced-motion: reduce) {
.billing-history-row,
.billing-details-table tbody tr {
animation: none;
}
}
.tab-pill-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.login-gate-card {
width: 100%;
max-width: 440px;
border-radius: 24px;
padding: 30px;
animation: modal-rise .2s ease both;
}
.login-gate-actions {
margin-top: 16px;
}
.passkey-login-option {
margin-top: 18px;
}
.passkey-login-option__rule {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 0.72rem;
text-align: center;
}
.passkey-login-option__rule::before,
.passkey-login-option__rule::after {
content: '';
height: 1px;
flex: 1;
background: var(--line);
}
.auth-brand {
display: flex;
align-items: center;
gap: 14px;
}
.auth-brand__logo {
width: 64px;
height: 64px;
flex: 0 0 64px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 14px 30px color-mix(in srgb, var(--accent) 14%, transparent);
}
.auth-brand__logo .brand-logo,
.auth-brand__logo .brand-logo-fallback {
width: 52px;
height: 52px;
flex-basis: 52px;
border: 0;
border-radius: 12px;
box-shadow: none;
}
.auth-brand__eyebrow {
margin: 0;
color: var(--accent);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.auth-brand__title {
margin: 0.35rem 0 0;
color: var(--text);
font-size: 1.35rem;
font-weight: 900;
letter-spacing: -0.035em;
line-height: 1.15;
}
.auth-brand__lead {
margin-top: 20px;
color: var(--muted);
font-size: 0.875rem;
line-height: 1.7;
}
.setup-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 30%),
radial-gradient(circle at 82% 12%, color-mix(in srgb, var(--success) 12%, transparent), transparent 26%),
var(--canvas);
}
.setup-card {
width: min(520px, 100%);
border-radius: 26px;
padding: 30px;
animation: modal-rise .22s ease both;
}
.setup-title {
margin: 0.35rem 0 0;
color: var(--text);
font-size: 1.35rem;
line-height: 1.15;
font-weight: 900;
letter-spacing: -0.035em;
}
.setup-desc {
margin-top: 8px;
color: var(--muted);
font-size: 0.875rem;
line-height: 1.7;
}
.setup-form {
display: grid;
gap: 14px;
margin-top: 22px;
}
.setup-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.setup-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.test-state {
margin-top: 8px;
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
}
.test-state.success {
color: #059669;
}
.test-state.error {
color: var(--danger);
}
.credential-result {
border-color: #fef3c7;
background: #fffbeb;
}
.credential-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: 8px;
background: var(--panel);
padding: 8px 10px;
}
.credential-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
font-weight: 700;
color: var(--text);
overflow-wrap: anywhere;
}
.credential-modal-overlay {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(0, 0, 0, 0.35);
animation: soft-fade-in .15s ease both;
}
.credential-modal {
width: min(580px, 100%);
border-radius: 16px;
padding: 24px;
border: 1px solid #fef3c7;
animation: modal-rise .2s ease both;
}
.credential-alert-title {
font-size: clamp(1.3rem, 3.5vw, 1.8rem);
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 800;
}
.credential-grid {
margin-top: 16px;
display: grid;
gap: 10px;
}
.credential-save-check {
margin-top: 14px;
display: flex;
align-items: flex-start;
gap: 10px;
border-radius: 10px;
border: 1px solid #fef3c7;
background: #fffbeb;
padding: 12px;
color: #92400e;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.5;
}
.app-shell {
min-height: 100vh;
padding: 16px;
}
.app-container {
width: min(1400px, 100%);
margin: 0 auto;
}
.page-header {
position: relative;
overflow: hidden;
border-radius: 14px;
padding: 18px 22px;
background:
radial-gradient(circle at 96% 16%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 23%),
color-mix(in srgb, var(--panel) 94%, var(--accent) 6%);
animation: panel-fade-up .25s ease both;
}
.page-header::after {
position: absolute;
right: -54px;
bottom: -86px;
width: 260px;
height: 160px;
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 50%;
content: "";
transform: rotate(-12deg);
pointer-events: none;
}
.page-header__row {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.page-brand {
min-width: 0;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 14px;
}
.brand-logo {
width: 62px;
height: 62px;
flex: 0 0 62px;
padding: 7px;
border-radius: 18px;
object-fit: contain;
background: #ffffff;
border: 1px solid var(--line);
box-shadow: 0 14px 30px color-mix(in srgb, var(--accent) 12%, transparent);
}
.brand-logo-fallback {
width: 62px;
height: 62px;
flex: 0 0 62px;
border-radius: 18px;
background: var(--accent-strong);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-weight: 900;
}
.brand-copy__eyebrow {
display: flex;
align-items: center;
gap: 7px;
color: var(--accent);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.13em;
}
.brand-copy__eyebrow::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
content: "";
box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 12%, transparent);
}
.brand-copy__title {
margin: 0.32rem 0 0;
color: var(--text);
font-size: clamp(1.35rem, 2.1vw, 1.8rem);
font-weight: 900;
letter-spacing: -0.045em;
line-height: 1.05;
}
.brand-copy__description {
margin: 0.4rem 0 0;
color: var(--muted);
font-size: 0.8rem;
line-height: 1.5;
}
.brand-logo-preview {
width: 72px;
height: 72px;
border-radius: 20px;
object-fit: contain;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
}
.login-brand-logo {
width: 68px;
height: 68px;
flex-basis: 68px;
border-radius: 20px;
}
.page-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
flex-shrink: 0;
}
.warning-banner {
margin-top: 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--warning) 36%, var(--line));
background: color-mix(in srgb, var(--warning) 10%, var(--panel));
color: var(--warning-text, var(--warning));
padding: 12px 16px;
font-size: 0.875rem;
animation: panel-fade-up .25s ease both;
}
.workspace-grid {
margin-top: 18px;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.sidebar-panel {
border-radius: 14px;
padding: 12px;
position: sticky;
top: 16px;
animation: panel-fade-up .25s ease both;
}
.menu-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-stack {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
align-self: start;
}
.mobile-bottom-nav {
display: none;
transition: transform .24s cubic-bezier(.22, .61, .36, 1), opacity .2s ease;
will-change: transform, opacity;
}
.mobile-bottom-nav.is-hidden {
opacity: 0;
pointer-events: none;
transform: translateY(calc(100% + 18px));
}
.section-panel {
border-radius: 14px;
padding: 18px 20px;
animation: panel-fade-up .3s ease both;
}
.table-scroll tbody tr {
transition: background .1s ease;
}
.table-scroll tbody tr:hover {
background: var(--panel);
}
.batch-bar-enter-active {
transition: all .25s ease;
}
.batch-bar-leave-active {
transition: all .2s ease;
}
.batch-bar-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.batch-bar-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.toolbar-grid {
display: grid;
gap: 12px;
align-items: center;
}
.toolbar-grid.status-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.status-toolbar app-select { flex: 0 0 220px; }
.status-toolbar .glass-input { flex: 1 1 260px; min-width: 220px; }
.status-toolbar .ghost-button,
.status-toolbar .solid-button { flex: 0 0 auto; min-width: 104px; }
.toolbar-grid.manage-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
width: 100%;
}
.manage-toolbar app-select:nth-of-type(1) { flex: 0 0 220px; }
.manage-toolbar app-select:nth-of-type(2),
.manage-toolbar app-select:nth-of-type(3) { flex: 0 0 180px; }
.manage-toolbar .glass-input { flex: 1 1 260px; min-width: 220px; }
.manage-toolbar .solid-button { flex: 0 0 auto; min-width: 104px; }
.manage-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.status-card-grid {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.instance-status-card {
display: flex;
flex-direction: column;
min-height: 0;
padding: 1.35rem;
}
.instance-status-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.instance-status-card__eyebrow,
.instance-status-card__fact-label,
.instance-status-card__network-label,
.instance-status-card__traffic-label {
color: var(--muted);
font-size: .68rem;
font-weight: 750;
letter-spacing: .05em;
line-height: 1.2;
}
.instance-status-card__name {
margin-top: 4px;
color: var(--text);
font-size: 1.06rem;
font-weight: 850;
letter-spacing: -.02em;
line-height: 1.25;
}
.instance-status-card__account {
margin-top: 4px;
color: var(--muted);
font-size: .72rem;
line-height: 1.35;
}
.instance-status-card__status {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.instance-status-card__facts {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px 16px;
margin-top: 20px;
}
.instance-status-card__fact {
min-width: 0;
}
.instance-status-card__fact-value {
margin-top: 5px;
color: var(--text);
font-size: .82rem;
font-weight: 650;
line-height: 1.35;
}
.instance-status-card__fact-value--system {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: .76rem;
font-weight: 550;
}
.instance-status-card__fact-value--primary {
color: var(--text);
font-size: .94rem;
font-weight: 850;
}
.instance-status-card__network {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-top: 16px;
border-top: 1px solid color-mix(in srgb, var(--line) 82%, transparent);
padding: 12px 2px 0;
}
.instance-status-card__network-divider {
width: 1px;
height: 28px;
background: var(--line);
}
.instance-status-card__network-value {
margin-top: 4px;
color: var(--text);
font-size: .8rem;
font-weight: 750;
line-height: 1.3;
}
.instance-status-card__network-value--ip {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-weight: 650;
}
.instance-status-card__traffic {
margin-top: 16px;
padding: 0;
}
.instance-status-card__traffic-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.instance-status-card__traffic-label {
color: var(--accent-strong);
letter-spacing: .02em;
}
.instance-status-card__traffic-percent {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
color: var(--text);
font-size: .62rem;
font-weight: 850;
font-variant-numeric: tabular-nums;
line-height: 1;
pointer-events: none;
text-shadow: 0 1px 2px color-mix(in srgb, var(--canvas) 42%, transparent);
}
.instance-status-card__traffic-percent.warning {
color: var(--danger);
}
.instance-status-card__traffic-value {
margin-top: 4px;
color: var(--text);
font-size: .9rem;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.instance-status-card__traffic-total {
color: var(--muted);
font-size: .72rem;
font-weight: 600;
}
.instance-status-card__traffic-progress {
position: relative;
height: 20px;
margin-top: 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 15%, var(--panel));
}
.instance-status-card__traffic-progress .progress-fill {
border-radius: inherit;
}
.instance-status-card__footer.is-empty {
justify-content: flex-end;
}
.instance-status-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--line) 76%, transparent);
}
.settings-grid {
margin-top: 18px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.settings-layout {
display: block;
margin-top: 24px;
}
.settings-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.settings-nav__button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 64px;
border: 1px solid transparent;
border-radius: 16px;
padding: 11px 13px;
background: color-mix(in srgb, var(--panel) 58%, transparent);
color: var(--muted);
text-align: left;
transition: background .18s ease, border-color .18s ease, color .18s ease, transform .18s ease;
}
.settings-nav__button:hover {
border-color: var(--line);
background: color-mix(in srgb, var(--panel) 84%, transparent);
color: var(--text);
transform: translateY(-1px);
}
.settings-nav__button.active {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
background: var(--accent-light);
color: var(--text);
box-shadow: inset 0 3px 0 var(--accent);
}
.settings-nav__index {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 800;
}
.settings-nav__button.active .settings-nav__index {
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
background: var(--accent);
color: #fff;
}
.settings-nav__label {
display: block;
font-size: 0.86rem;
font-weight: 800;
line-height: 1.2;
}
.settings-nav__hint {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 0.7rem;
line-height: 1.4;
}
.settings-workspace {
min-width: 0;
margin-top: 12px;
}
/* Keep the settings form dense without changing the larger dashboard cards. */
.settings-workspace .settings-grid {
margin-top: 14px;
gap: 14px;
}
.settings-workspace .metric-card {
border-radius: 12px;
padding: 1rem;
}
.settings-workspace .metric-card.space-y-4 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.75rem;
}
.settings-workspace .space-y-3 > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.65rem;
}
.settings-workspace .glass-input,
.settings-workspace .glass-textarea {
min-height: 36px;
padding: 0.4rem 0.65rem;
font-size: 0.82rem;
}
.settings-workspace .glass-textarea {
min-height: 88px;
}
.settings-workspace .select-trigger {
min-height: 36px;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.82rem;
}
.settings-workspace .switch-row {
border-radius: 12px;
padding: 0.45rem 0.72rem;
}
.settings-workspace .ghost-button,
.settings-workspace .solid-button {
padding: 0.42rem 0.72rem;
font-size: 0.76rem;
}
.settings-workspace .two-col-grid {
gap: 8px;
}
.settings-brand-card {
border-radius: 14px !important;
padding: 0.7rem 0.8rem !important;
overflow: hidden;
}
.settings-brand-card label {
margin-bottom: 0.65rem !important;
letter-spacing: 0.01em;
}
.settings-brand-row {
display: grid !important;
grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 14px !important;
}
.settings-brand-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.settings-brand-input-row .glass-input {
min-width: 0;
flex: 1 1 auto;
}
.settings-brand-input-row > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-brand-card .brand-logo-preview,
.settings-brand-card .brand-logo-fallback {
width: 52px !important;
height: 52px !important;
flex-basis: 52px !important;
border-radius: 14px !important;
}
.settings-brand-card .brand-logo-preview {
background: #e6eff1;
border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
box-shadow: 0 8px 18px color-mix(in srgb, var(--canvas) 44%, transparent);
}
.ddns-provider-card {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
border: 1px solid var(--line) !important;
border-radius: 12px !important;
padding: 10px 12px !important;
background: var(--control-bg) !important;
}
.ddns-provider-card__badge {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: 0 0 32px;
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line));
border-radius: 9px;
background: color-mix(in srgb, var(--accent-light) 78%, var(--panel));
color: var(--accent);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.03em;
}
.ddns-provider-card__title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 0.82rem;
font-weight: 800;
}
.ddns-provider-card__tag {
border-radius: 999px;
padding: 2px 6px;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
color: var(--accent);
font-size: 0.62rem;
font-weight: 700;
}
.ddns-provider-card__hint {
margin-top: 2px;
color: var(--muted);
font-size: 0.7rem;
line-height: 1.4;
}
.daily-summary-card {
display: grid;
gap: 10px;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--accent-light) 42%, var(--control-bg));
}
.daily-summary-card__title {
color: var(--text);
font-size: 0.84rem;
font-weight: 800;
}
.daily-summary-card__hint {
margin-top: 3px;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.45;
}
.daily-summary-card__time {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text);
font-size: 0.78rem;
font-weight: 700;
}
.update-center-card {
position: relative;
display: grid;
gap: 12px;
overflow: hidden;
padding: 1.05rem 1.1rem 1rem;
transition: border-color .24s ease, box-shadow .24s ease, background .24s ease;
}
.update-center-card.has-update {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
background:
radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--accent) 13%, transparent), transparent 32%),
color-mix(in srgb, var(--accent-light) 24%, var(--panel));
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
}
.update-center-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-bottom: 11px;
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
}
.update-center-card__intro {
min-width: 0;
}
.update-center-card__eyebrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.update-center-card__title-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 2px;
}
.update-center-card__title-row h3 {
margin: 0;
}
.update-center-card__state {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--control-bg);
color: var(--muted);
font-size: .64rem;
font-weight: 800;
letter-spacing: .02em;
}
.update-center-card__state--new {
border-color: color-mix(in srgb, var(--accent) 34%, var(--line));
background: color-mix(in srgb, var(--accent-light) 70%, var(--panel));
color: var(--accent-strong);
}
.update-center-card__state--current {
border-color: color-mix(in srgb, var(--success) 30%, var(--line));
background: color-mix(in srgb, var(--success-bg) 78%, var(--panel));
color: var(--success-text);
}
.update-center-card__state-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 13%, transparent);
}
.update-center-card__link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
flex: 0 0 auto;
min-height: 33px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: color-mix(in srgb, var(--panel) 78%, var(--control-bg));
color: var(--accent);
font-size: .72rem;
font-weight: 800;
text-decoration: none;
transition: border-color .2s ease, background .2s ease, transform .2s ease;
}
.update-center-card__link:hover {
border-color: color-mix(in srgb, var(--accent) 44%, var(--line));
background: var(--accent-light);
transform: translateY(-1px);
}
.update-center-card__link svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
}
.update-center-card__body {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: stretch;
gap: 12px;
}
.update-release {
display: grid;
grid-template-columns: minmax(0, 1fr) 26px minmax(0, 1fr);
align-items: stretch;
gap: 6px;
min-width: 0;
padding: 0;
}
.update-release__version {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: color-mix(in srgb, var(--control-bg) 66%, var(--panel));
}
.update-release__version.latest-version {
border-color: color-mix(in srgb, var(--accent) 34%, var(--line));
background: color-mix(in srgb, var(--accent-light) 68%, var(--panel));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 7%, transparent);
animation: update-version-pulse 2.8s ease-in-out infinite;
}
.update-release__label {
color: var(--muted);
font-size: .62rem;
font-weight: 700;
letter-spacing: .04em;
}
.update-release__value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.update-release__value {
overflow: hidden;
color: var(--text);
font-size: .9rem;
font-weight: 850;
letter-spacing: -.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.update-release__version.latest-version .update-release__value {
color: var(--accent-strong);
}
.update-release__badge {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-contrast);
font-size: .61rem;
font-weight: 800;
letter-spacing: .03em;
white-space: nowrap;
}
.update-release__connector {
display: grid;
place-items: center;
color: var(--muted);
}
.update-release__connector svg {
width: 17px;
height: 17px;
}
.update-center-card__actions {
display: flex;
align-items: stretch;
justify-content: center;
}
.update-center-card__actions > button {
width: 100%;
min-height: 36px;
}
.update-center-card__notice {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--control-bg) 72%, transparent);
}
.update-center-card__notice.is-warning {
border-color: var(--warning-border);
background: var(--warning-bg);
}
.update-center-card__notice.is-success {
border-color: var(--success-border);
background: var(--success-bg);
}
.update-center-card__notice.is-error {
border-color: var(--danger-border);
background: var(--danger-bg);
}
.update-center-card__notice-icon {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid currentColor;
border-radius: 50%;
color: var(--accent);
font-size: .68rem;
font-weight: 900;
}
.update-center-card__notice.is-warning .update-center-card__notice-icon {
color: var(--warning-text);
}
.update-center-card__notice.is-success .update-center-card__notice-icon {
color: var(--success-text);
}
.update-center-card__notice.is-error .update-center-card__notice-icon {
color: var(--danger-text);
}
.update-center-card__notice strong {
display: block;
color: var(--text);
font-size: .72rem;
font-weight: 800;
}
.update-center-card__notice p {
margin: 3px 0 0;
color: var(--muted);
font-size: .72rem;
line-height: 1.45;
}
.online-update-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 4vw, 32px);
background: color-mix(in srgb, var(--canvas) 68%, rgba(7, 22, 34, .72));
backdrop-filter: blur(18px);
animation: soft-fade-in .2s ease both;
}
.online-update-card {
width: min(540px, 100%);
padding: clamp(24px, 5vw, 40px);
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line));
border-radius: 28px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 26px 80px color-mix(in srgb, #071622 22%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 8%, transparent);
animation: modal-rise .24s cubic-bezier(.22, .61, .36, 1) both;
}
.online-update-card__top {
display: flex;
align-items: flex-start;
gap: 16px;
}
.online-update-card__mark {
position: relative;
display: grid;
place-items: center;
width: 48px;
height: 48px;
flex: 0 0 48px;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
border-radius: 16px;
background: var(--accent-light);
color: var(--accent-strong);
}
.online-update-card__mark::before,
.online-update-card__mark::after {
content: '';
position: absolute;
border: 1px solid currentColor;
border-radius: 50%;
opacity: .45;
animation: online-update-orbit 1.8s ease-in-out infinite;
}
.online-update-card__mark::before {
width: 20px;
height: 20px;
}
.online-update-card__mark::after {
width: 8px;
height: 8px;
animation-delay: -.9s;
}
.online-update-card__eyebrow {
margin: 0 0 5px;
color: var(--accent-strong);
font-size: .68rem;
font-weight: 850;
letter-spacing: .08em;
text-transform: uppercase;
}
.online-update-card__title {
margin: 0;
color: var(--text);
font-size: clamp(1.25rem, 4vw, 1.6rem);
font-weight: 900;
letter-spacing: -.04em;
}
.online-update-card__copy {
margin: 7px 0 0;
color: var(--muted);
font-size: .78rem;
line-height: 1.55;
}
.online-update-card__progress-wrap {
display: grid;
gap: 10px;
margin-top: 28px;
}
.online-update-card__progress {
overflow: hidden;
height: 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 74%, transparent);
}
.online-update-card__progress > span {
display: block;
height: 100%;
min-width: 6%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 58%, #fff) 48%, var(--accent) 100%);
background-size: 180% 100%;
box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 42%, transparent);
transition: width .35s ease;
animation: update-progress-sheen 1.35s linear infinite;
}
.online-update-card__progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: .7rem;
font-weight: 750;
}
.online-update-card__progress-meta strong {
color: var(--accent-strong);
font-size: .82rem;
}
.online-update-card__versions {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.online-update-card__version {
display: grid;
gap: 4px;
min-width: 0;
}
.online-update-card__version-label {
color: var(--muted);
font-size: .65rem;
font-weight: 750;
}
.online-update-card__version strong {
overflow: hidden;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: .8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.online-update-card__versions > svg {
width: 16px;
height: 16px;
color: var(--muted);
}
.online-update-card__footnote {
margin: 18px 0 0;
color: var(--muted);
font-size: .68rem;
line-height: 1.5;
text-align: center;
}
@keyframes update-progress-sheen {
from { background-position: 100% 0; }
to { background-position: -80% 0; }
}
@keyframes online-update-orbit {
0%, 100% { transform: scale(.72); opacity: .25; }
50% { transform: scale(1.08); opacity: .7; }
}
.online-update-enter-active,
.online-update-leave-active {
transition: opacity .2s ease;
}
.online-update-enter-from,
.online-update-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.online-update-card__mark::before,
.online-update-card__mark::after,
.online-update-card__progress > span {
animation: none;
}
}
.about-section {
display: grid;
gap: 16px;
}
.about-hero {
position: relative;
display: flex;
align-items: center;
gap: 16px;
overflow: hidden;
padding: 18px;
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line));
border-radius: 16px;
background:
radial-gradient(circle at 92% 8%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 34%),
color-mix(in srgb, var(--accent-light) 30%, var(--panel));
}
.about-hero::after {
position: absolute;
right: 18px;
bottom: -38px;
width: 120px;
height: 120px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-radius: 50%;
content: "";
}
.about-hero__mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
flex: 0 0 48px;
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line));
border-radius: 14px;
background: var(--accent);
color: var(--accent-contrast);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: .72rem;
font-weight: 900;
letter-spacing: .02em;
box-shadow: 0 9px 20px color-mix(in srgb, var(--accent) 18%, transparent);
}
.about-hero__title {
margin: 3px 0 0;
color: var(--text);
font-size: 1.2rem;
font-weight: 900;
letter-spacing: -.025em;
}
.about-hero__copy {
max-width: 620px;
margin: 5px 0 0;
color: var(--muted);
font-size: .78rem;
line-height: 1.6;
}
.about-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(250px, .7fr);
gap: 16px;
align-items: start;
}
.about-donation-card {
display: grid;
gap: 14px;
align-content: start;
background:
linear-gradient(145deg, color-mix(in srgb, var(--panel) 90%, transparent), color-mix(in srgb, var(--accent-light) 32%, var(--panel)));
}
.about-donation-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.about-donation-card__badge {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line));
border-radius: 11px;
background: var(--accent-light);
color: var(--accent-strong);
font-size: .86rem;
font-weight: 900;
}
.about-donation-card__copy {
margin: 4px 0 0;
color: var(--muted);
font-size: .75rem;
line-height: 1.6;
}
.about-donation-card__code {
display: block;
width: min(100%, 230px);
margin: 2px auto 0;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
box-shadow: 0 12px 26px color-mix(in srgb, var(--canvas) 16%, transparent);
}
.about-donation-card__wallets {
display: grid;
gap: 7px;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--control-bg) 76%, var(--panel));
color: var(--muted);
font-size: .68rem;
line-height: 1.5;
}
.about-donation-card__wallets code {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: .66rem;
overflow-wrap: anywhere;
}
@media (max-width: 820px) {
.about-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.about-hero {
align-items: flex-start;
padding: 15px;
}
.about-hero__mark {
width: 42px;
height: 42px;
flex-basis: 42px;
border-radius: 12px;
}
}
@keyframes update-version-pulse {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 7%, transparent); }
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 13%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.update-release__version.latest-version {
animation: none;
}
}
@media (max-width: 600px) {
.update-center-card {
padding: .95rem;
}
.update-center-card__head {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
.update-center-card__link {
align-self: flex-start;
}
.update-center-card__body {
grid-template-columns: 1fr;
gap: 12px;
}
.update-release {
grid-template-columns: 1fr;
gap: 5px;
}
.update-release__connector {
min-height: 18px;
transform: rotate(90deg);
}
.update-center-card__actions {
align-items: stretch;
flex-direction: column;
}
.update-center-card__actions > button {
width: 100%;
}
}
.settings-panel-enter-active,
.settings-panel-leave-active {
transition: opacity .2s ease, transform .24s cubic-bezier(.22, .61, .36, 1);
}
.settings-panel-enter-from,
.settings-panel-leave-to {
opacity: 0;
transform: translateY(8px);
}
.two-col-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.mobile-manage-grid {
margin-top: 24px;
display: none;
gap: 16px;
}
.top-action-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.muted-text {
color: var(--muted);
}
.muted-text-strong {
color: var(--muted);
}
.body-text {
color: #334155;
}
.text-dark-button {
background: var(--accent) !important;
color: #fff !important;
}
[data-theme="dark"] .text-dark-button {
background: var(--accent-strong) !important;
}
.compact-button {
padding: 0.38rem 0.62rem !important;
min-width: 48px;
font-size: 0.74rem;
}
.log-card {
border-radius: 14px !important;
padding: 1rem !important;
}
.desktop-visible {
display: block;
}
.mobile-visible {
display: none;
}
.page-header p,
.section-panel p,
.section-panel label,
.section-panel span,
.section-panel div,
.section-panel td,
.section-panel th {
word-break: break-word;
}
.full-width-button {
min-width: 120px;
}
.text-slate-700 {
color: var(--text);
}
.text-slate-800 {
color: var(--text);
}
.text-slate-500 {
color: var(--muted);
}
.text-slate-400 {
color: var(--muted);
}
.text-\[\#6f7787\] {
color: #6f7787;
}
.text-\[\var(--muted)\] {
color: var(--muted);
}
.text-\[\#334155\] {
color: var(--text);
}
.rounded-\[18px\] {
border-radius: 18px;
}
.rounded-\[20px\] {
border-radius: 20px;
}
.rounded-\[22px\] {
border-radius: 22px;
}
.rounded-\[24px\] {
border-radius: 24px;
}
.rounded-\[30px\] {
border-radius: 30px;
}
.rounded-\[34px\] {
border-radius: 34px;
}
.rounded-\[38px\] {
border-radius: 38px;
}
.tracking-\[0\.18em\] {
letter-spacing: 0.18em;
}
.tracking-\[0\.12em\] {
letter-spacing: 0.12em;
}
.min-w-\[120px\] {
min-width: 120px;
}
.\!rounded-\[22px\] {
border-radius: 22px !important;
}
.\!p-4 {
padding: 1rem !important;
}
.\!px-3 {
padding-left: 0.75rem !important;
padding-right: 0.75rem !important;
}
.\!py-2 {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 1280px) {
.status-card-grid {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
/* The management table has eight columns and becomes cramped before the mobile breakpoint. */
.manage-table-shell.desktop-visible {
display: none !important;
}
.mobile-manage-grid {
display: grid;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1180px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.sidebar-panel {
position: static;
}
.toolbar-grid.manage-toolbar {
align-items: stretch;
}
}
@media (max-width: 1023px) {
.page-header {
padding: 22px;
}
.settings-layout {
grid-template-columns: 1fr;
gap: 16px;
}
.settings-nav {
position: static;
display: flex;
overflow-x: auto;
gap: 8px;
padding: 2px 2px 6px;
scrollbar-width: none;
}
.settings-nav::-webkit-scrollbar {
display: none;
}
.settings-nav__button {
min-width: 176px;
flex: 0 0 176px;
}
.page-header__row {
flex-direction: column;
}
.page-actions {
width: 100%;
justify-content: flex-start;
}
.section-panel {
padding: 20px;
}
.toolbar-grid.status-toolbar,
.toolbar-grid.manage-toolbar {
align-items: stretch;
}
.manage-head-actions {
width: 100%;
justify-content: flex-start;
}
.status-toolbar app-select,
.status-toolbar .glass-input,
.status-toolbar .ghost-button,
.status-toolbar .solid-button,
.manage-toolbar app-select,
.manage-toolbar .glass-input,
.manage-toolbar .solid-button {
flex: 1 1 100%;
min-width: 0;
}
.status-card-grid {
grid-template-columns: 1fr;
}
.desktop-visible {
display: none !important;
}
.mobile-visible,
.mobile-manage-grid {
display: grid;
}
.account-row {
display: none;
}
.mobile-stack {
display: block;
}
.account-billing {
max-width: none;
text-align: left;
margin: 0;
}
.sidebar-panel {
display: none;
}
.content-stack {
gap: 18px;
}
.modal-form-grid {
grid-template-columns: 1fr;
}
.summary-list {
grid-template-columns: 1fr;
}
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1023px) {
.app-shell {
padding: 14px 14px 108px;
}
.settings-save-float {
left: 14px;
right: 14px;
bottom: calc(104px + env(safe-area-inset-bottom));
max-width: none;
justify-content: space-between;
}
.mobile-bottom-nav {
position: fixed;
left: 14px;
right: 14px;
bottom: 14px;
z-index: 45;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px;
padding: 10px;
border-radius: 24px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
border: 1px solid var(--line);
box-shadow: 0 20px 40px color-mix(in srgb, var(--canvas) 70%, transparent);
backdrop-filter: blur(16px);
}
.mobile-bottom-nav__button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 58px;
padding: 8px 2px;
border-radius: 18px;
color: var(--muted);
transition: background .18s ease, color .18s ease, transform .18s ease;
}
.mobile-bottom-nav__button.active {
background: var(--accent-light);
color: var(--text);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
}
.mobile-bottom-nav__button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-bottom-nav__button span:last-child {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.02em;
}
}
@media (max-width: 767px) {
.app-shell {
padding: 10px 10px 96px;
}
.page-header {
border-radius: 18px;
padding: 12px 14px;
}
.page-header__row {
flex-direction: row;
align-items: center;
gap: 10px;
}
.page-brand {
flex: 1 1 auto;
}
.brand-lockup {
gap: 9px;
}
.brand-logo,
.brand-logo-fallback {
width: 42px;
height: 42px;
flex-basis: 42px;
padding: 4px;
border-radius: 12px;
font-size: 0.95rem;
}
.brand-copy__eyebrow {
display: none;
}
.brand-copy__eyebrow::before {
display: none;
}
.brand-copy__title {
font-size: 0.95rem;
line-height: 1.2;
white-space: nowrap;
}
.brand-copy__description {
display: none;
}
.auth-brand__logo {
width: 56px;
height: 56px;
flex-basis: 56px;
border-radius: 16px;
}
.auth-brand__logo .brand-logo,
.auth-brand__logo .brand-logo-fallback {
width: 46px;
height: 46px;
flex-basis: 46px;
}
.auth-brand__title {
font-size: 1.2rem;
}
.settings-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: visible;
gap: 6px;
padding: 0;
}
.settings-nav__button {
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 6px;
padding: 9px 8px;
}
.settings-nav__index {
width: 24px;
height: 24px;
flex-basis: 24px;
border-radius: 8px;
font-size: 0.62rem;
}
.settings-nav__label {
font-size: 0.76rem;
}
.settings-nav__hint {
display: none;
}
.page-actions {
flex: 0 0 auto;
width: auto;
justify-content: flex-end;
gap: 6px;
}
.page-actions .chip {
padding: 0.18rem 0.4rem;
font-size: 0.64rem;
}
.page-actions .full-width-button {
width: auto;
min-width: 0;
padding: 0.42rem 0.6rem;
font-size: 0.68rem;
}
.metric-card {
padding: 1rem;
}
.page-header,
.section-panel {
border-radius: 18px;
}
.glass-input,
.glass-select,
.glass-textarea {
padding: 0.75rem 0.85rem;
border-radius: 16px;
}
.account-modal-overlay {
align-items: center;
padding: 8px;
}
.account-modal {
width: 100%;
max-height: min(740px, calc(100vh - 32px));
max-height: min(740px, calc(100dvh - 32px));
padding: 16px;
border-radius: 20px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.account-modal > .section-head {
flex-wrap: nowrap;
align-items: flex-start;
gap: 10px;
}
.account-modal > .section-head > div:first-child {
min-width: 0;
}
.account-modal > .section-head .section-title {
font-size: 1.15rem;
}
.account-modal__body {
margin-top: 14px;
padding-right: 3px;
}
.account-modal > .top-action-row {
flex: 0 0 auto;
gap: 8px;
margin-top: 14px !important;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.account-modal > .top-action-row > button {
flex: 1 1 0;
min-width: 0;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
app-select.glass-select,
.modal-form-grid app-select.glass-select,
.account-modal app-select.glass-select {
display: block;
width: 100%;
}
app-select.glass-select .select-shell {
width: 100%;
}
.select-trigger {
border-radius: 16px;
min-height: 46px;
}
.settings-save-float {
left: 12px;
right: 12px;
bottom: calc(104px + env(safe-area-inset-bottom));
gap: 8px;
padding-left: 12px;
border-radius: 18px;
}
.settings-save-float__hint {
overflow: hidden;
text-overflow: ellipsis;
}
.settings-save-float > .solid-button {
padding-right: 0.72rem;
padding-left: 0.72rem;
}
.two-col-grid {
grid-template-columns: 1fr;
}
.setup-overlay {
align-items: flex-start;
padding: 18px;
padding-top: 12vh;
}
.setup-card {
border-radius: 20px;
padding: 22px;
}
.setup-title {
font-size: 1.45rem;
}
.setup-row {
grid-template-columns: 1fr;
}
.setup-actions .solid-button {
width: 100%;
}
.mobile-stack .row-actions {
max-width: none;
width: 100%;
justify-content: stretch !important;
flex-wrap: nowrap;
gap: 10px;
}
.mobile-stack .row-actions > button {
flex: 1 1 0;
}
}
@media (max-width: 600px) {
.settings-brand-row {
grid-template-columns: 1fr;
}
.settings-brand-card .brand-logo-preview,
.settings-brand-card .brand-logo-fallback {
justify-self: start;
}
.settings-brand-input-row {
grid-template-columns: 1fr;
}
.settings-brand-input-row > div {
width: 100%;
}
.settings-brand-input-row > div > .ghost-button,
.settings-brand-input-row > div > .danger-button {
flex: 1 1 auto;
}
}
</style>
</head>
<body>
<div id="app" v-cloak class="app-shell">
<div class="app-container">
<header v-if="initialized && !criticalError && isAdmin" class="glass-panel page-header">
<div class="page-header__row">
<div class="page-brand">
<div class="brand-lockup">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="页面 Logo" class="brand-logo">
<div v-else class="brand-logo-fallback">C</div>
<div class="brand-copy">
<p class="brand-copy__eyebrow">阿里云 ECS · 资源控制</p>
<h1 class="brand-copy__title">{{ appBrandName }}</h1>
<p class="brand-copy__description">实例、流量与定时策略,一处管理。</p>
</div>
</div>
</div>
<div class="page-actions">
<div v-if="initialized" class="chip">
<span class="status-dot" :style="{ background: cronWarning ? '#eab308' : '#22c55e' }"></span>
<span>{{ cronWarning ? '自动检测可能中断' : '自动检测正常' }}</span>
</div>
<button v-if="initialized && !checkingLogin && !criticalError" @click="toggleAdmin"
class="solid-button full-width-button">
{{ isAdmin ? '退出管理' : '管理员登录' }}
</button>
</div>
</div>
</header>
<div v-if="isAdmin && cronWarning && initialized && !criticalError" class="warning-banner">
超过 3 分钟未检测到自动检测任务,请检查后台监控任务是否正常运行。
</div>
<div v-if="initialized && !loadingCheckInit && !criticalError && !isAdmin" class="fixed inset-0 z-40 flex items-center justify-center p-4">
<div class="glass-panel login-gate-card">
<div class="auth-brand">
<div class="auth-brand__logo">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="阿里云 Logo" class="brand-logo">
<div v-else class="brand-logo-fallback">C</div>
</div>
<div>
<p class="auth-brand__eyebrow">{{ appBrandName }}</p>
<h2 class="auth-brand__title">管理员登录</h2>
</div>
</div>
<p class="auth-brand__lead">{{ passwordLoginEnabled ? '登录后查看实例状态、流量用量、定时策略和系统日志。' : '密码登录已关闭,请使用 Passkey 验证后进入控制台。' }}</p>
<div v-if="passwordLoginEnabled">
<input
v-model="passwordInput"
@keyup.enter="performLogin"
type="password"
class="glass-input mt-6"
placeholder="请输入管理员密码"
>
<div class="login-gate-actions flex">
<button @click="performLogin" class="solid-button flex-1">登录</button>
</div>
</div>
<div v-if="passkeyAvailable" class="passkey-login-option">
<div class="passkey-login-option__rule"><span></span></div>
<button v-if="passkeySupported" @click="loginWithPasskey" class="ghost-button full-width-button" :disabled="passkeyBusy">
{{ passkeyBusy ? '验证中...' : '使用 Passkey 登录' }}
</button>
<p v-if="passkeySupported" class="mt-2 text-center text-xs leading-5 text-[var(--muted)]">使用设备指纹、面容或安全密钥,无需输入管理员密码。</p>
<p v-else class="mt-2 text-center text-xs leading-5 text-amber-700">{{ passkeySupportHint }}</p>
</div>
<div v-if="!passwordLoginEnabled && !passkeyAvailable" class="mt-6 rounded-[18px] border border-red-200 bg-red-50/80 px-4 py-3 text-sm leading-6 text-red-700">
当前没有可用的 Passkey请联系管理员恢复密码登录。
</div>
</div>
</div>
<div v-if="initialized && !criticalError && isAdmin" class="workspace-grid">
<aside class="glass-panel sidebar-panel">
<div class="menu-stack">
<button v-for="(item, index) in menuItems" :key="item.key" @click="currentMenu = item.key"
class="menu-button" :class="{ active: currentMenu === item.key }">
<span class="menu-index">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="flex-1">
<span class="menu-label">{{ item.label }}</span>
<span class="menu-hint">{{ item.description }}</span>
</span>
</button>
</div>
</aside>
<main class="content-stack">
<section v-if="initialWorkspaceLoading" class="glass-panel section-panel content-skeleton" aria-label="正在加载控制台内容">
<div class="content-skeleton__head">
<div class="content-skeleton__line content-skeleton__line--short"></div>
<div class="content-skeleton__line content-skeleton__line--title"></div>
<div class="content-skeleton__line content-skeleton__line--copy"></div>
</div>
<div class="content-skeleton__grid">
<div class="content-skeleton__block"></div>
<div class="content-skeleton__block"></div>
</div>
</section>
<template v-if="!initialWorkspaceLoading">
<section v-if="currentMenu === 'status'" class="glass-panel section-panel">
<div class="section-head">
<div>
<p class="subtle-label">实例总览</p>
<h2 class="section-title">实例状态</h2>
<p class="mt-1 text-sm text-slate-500">以卡片方式展示所有已同步实例,可按账号筛选,并支持通过备注或实例标识搜索。</p>
</div>
<div class="toolbar-grid status-toolbar">
<app-select
v-model="statusFilter.account"
class="glass-select"
placeholder="所有账号"
:options="[{ value: '', label: '所有账号' }, ...accountOptions]"
/>
<input v-model="statusFilter.search" class="glass-input" placeholder="搜索备注、实例名称或实例编号">
<button @click="syncCloudInstances" class="ghost-button" :disabled="syncingInstances">
{{ syncingInstances ? '同步中...' : '手动同步' }}
</button>
<button @click="fetchStatusData" class="solid-button" :disabled="loading">
{{ loading ? '刷新中...' : '刷新状态' }}
</button>
</div>
</div>
<div class="status-card-grid">
<article v-for="item in filteredStatusInstances" :key="'card-' + item.id" class="metric-card instance-status-card">
<div class="instance-status-card__header">
<div class="min-w-0">
<p class="instance-status-card__eyebrow">实例备注</p>
<h3 class="instance-status-card__name truncate">{{ item.remark || item.instanceName || item.instanceId }}</h3>
<p class="instance-status-card__account">{{ item.accountLabel }}</p>
</div>
<div class="instance-status-card__status">
<span v-if="!(item.status === 'Stopped' && item.stoppedMode === 'StopCharging')" class="status-pill" :class="statusClass(item.instanceStatus)">
{{ statusText(item.instanceStatus) }}
</span>
<span v-else class="status-pill status-unknown">
节省停机
</span>
<span v-if="item.status === 'Running' && item.healthStatus === 'Initializing'" class="status-pill status-transition !text-[10px] animate-pulse">
操作系统启动中
</span>
</div>
</div>
<div class="instance-status-card__facts">
<div class="instance-status-card__fact">
<p class="instance-status-card__fact-label">镜像系统</p>
<p class="instance-status-card__fact-value instance-status-card__fact-value--system truncate" :title="item.osName">{{ item.osName || '-' }}</p>
</div>
<div class="instance-status-card__fact">
<p class="instance-status-card__fact-label">实例规格</p>
<p class="instance-status-card__fact-value instance-status-card__fact-value--primary">{{ formatInstanceSpec(item) }}</p>
</div>
</div>
<div class="instance-status-card__network">
<div class="min-w-0">
<p class="instance-status-card__network-label">公网 IP</p>
<p class="instance-status-card__network-value instance-status-card__network-value--ip truncate">{{ isAdmin ? (item.publicIp || '-') : '登录后可见' }}</p>
</div>
<span class="instance-status-card__network-divider" aria-hidden="true"></span>
<div class="min-w-0">
<p class="instance-status-card__network-label">带宽峰值</p>
<p class="instance-status-card__network-value">{{ isAdmin ? formatPublicBandwidth(item) : '登录后可见' }}</p>
</div>
</div>
<div class="instance-status-card__traffic">
<div class="instance-status-card__traffic-head">
<span class="instance-status-card__traffic-label">{{ item.trafficScope === 'account' ? '账号聚合流量' : 'CMS 实例流量' }}</span>
</div>
<p class="instance-status-card__traffic-value">
{{ formatTrafficValue(item.flow_used) }} <span class="instance-status-card__traffic-total">/ {{ item.flow_total }} GB</span>
</p>
<div class="progress-track instance-status-card__traffic-progress" role="progressbar" :aria-valuenow="item.percentageOfUse" aria-valuemin="0" aria-valuemax="100" :aria-label="`流量使用率 ${formatPercent(item.percentageOfUse)}%`">
<div class="progress-fill h-full" :class="{ warning: item.rate95 }"
:style="{ width: Math.min(item.percentageOfUse, 100) + '%' }"></div>
<span class="instance-status-card__traffic-percent" :class="{ warning: item.rate95 }">{{ formatPercent(item.percentageOfUse) }}%</span>
</div>
<p v-if="hasTrafficIssue(item)" class="mt-2 text-xs text-[#dc2626]">{{ trafficIssueText(item) }}</p>
</div>
<div class="instance-status-card__footer text-xs text-[var(--muted)]" :class="{ 'is-empty': !item.lastUpdated }">
<div v-if="item.lastUpdated" class="flex items-center gap-1.5 text-[11px] text-[var(--muted)]">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>{{ item.lastUpdated }}</span>
</div>
<button v-if="isAdmin" @click="refreshInstanceCard(item.id)" class="ghost-button !py-1.5 !px-3" :disabled="isRefreshingInstance(item.id)">
<div class="flex items-center gap-1.5">
<svg :class="{'animate-spin': isRefreshingInstance(item.id)}" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
{{ isRefreshingInstance(item.id) ? '刷新中' : '刷新状态' }}
</div>
</button>
</div>
</article>
<div v-if="filteredStatusInstances.length === 0" class="metric-card" style="grid-column: 1 / -1;">
<p class="text-center text-sm text-[var(--muted)]">暂无实例状态数据。</p>
</div>
</div>
</section>
<section v-if="currentMenu === 'manage' && isAdmin" class="glass-panel section-panel">
<div class="section-head">
<div>
<p class="subtle-label">实例管理</p>
<h2 class="section-title">实例管理</h2>
<p class="mt-1 text-sm text-slate-500">管理所有账号组同步下来的实例,可按账号、区域、状态过滤并执行单实例操作。</p>
</div>
<div class="manage-head-actions">
<button @click="openCreateEcsModal" class="solid-button">
一键创建 ECS
</button>
<button @click="fetchAllInstances(true)" class="ghost-button" :disabled="fetchingAllInstances">
{{ fetchingAllInstances ? '同步中...' : '手动同步' }}
</button>
</div>
<div class="toolbar-grid manage-toolbar">
<app-select
v-model="manageFilter.account"
class="glass-select"
placeholder="所有账号"
:options="[{ value: '', label: '所有账号' }, ...accountOptions]"
/>
<app-select
v-model="manageFilter.region"
class="glass-select"
placeholder="所有区域"
:options="[{ value: '', label: '所有区域' }, ...aliyunRegions.map(region => ({ value: region.id, label: region.name }))]"
/>
<app-select
v-model="manageFilter.status"
class="glass-select"
placeholder="所有状态"
:options="[
{ value: '', label: '所有状态' },
{ value: 'Running', label: '运行中' },
{ value: 'Stopped', label: '已停止' },
{ value: 'Starting', label: '启动中' },
{ value: 'Stopping', label: '停止中' },
{ value: 'Releasing', label: '释放中' },
{ value: 'ReleaseFailed', label: '释放失败' },
{ value: 'Unknown', label: '未知' }
]"
/>
<input v-model="manageFilter.search" class="glass-input" placeholder="搜索备注、实例名称或实例编号">
</div>
</div>
<div class="table-shell desktop-visible manage-table-shell" style="margin-top: 24px;">
<div class="bg-white/65 px-5 py-3 text-xs font-bold uppercase tracking-[0.18em] text-[var(--muted)]">
列表模式
</div>
<div class="overflow-x-auto">
<div class="min-w-[1120px]">
<div class="account-row summary-table-head" style="grid-template-columns: minmax(145px,1.12fr) 82px 70px minmax(112px,0.82fr) minmax(128px,0.95fr) 112px minmax(120px,0.82fr) minmax(210px,1fr);">
<div>账号 / 实例</div>
<div>区域</div>
<div>状态</div>
<div>规格</div>
<div>地址</div>
<div class="center-column">公网带宽峰值</div>
<div class="center-column">CMS 实例流量</div>
<div class="center-column">操作</div>
</div>
<div v-for="inst in filteredManagedInstances" :key="'manage-row-' + inst.id"
class="account-row"
style="grid-template-columns: minmax(145px,1.12fr) 82px 70px minmax(112px,0.82fr) minmax(128px,0.95fr) 112px minmax(120px,0.82fr) minmax(210px,1fr);">
<div class="summary-cell">
<div class="summary-primary truncate" :title="inst.accountLabel || inst.remark || inst.instanceName">{{ inst.accountLabel || inst.remark || inst.instanceName }}</div>
<div class="summary-secondary font-mono truncate" :title="inst.instanceId">{{ inst.instanceId }}</div>
</div>
<div class="summary-cell">
<div class="summary-primary">{{ inst.regionName }}</div>
</div>
<div class="summary-cell">
<span class="status-pill" :class="statusClass(inst.instanceStatus)">{{ statusText(inst.instanceStatus) }}</span>
</div>
<div class="summary-cell">
<div class="summary-primary text-sm">{{ inst.instanceType || '-' }}</div>
</div>
<div class="summary-cell">
<div class="summary-primary font-mono text-sm">{{ inst.publicIp || inst.privateIp || '-' }}</div>
<div class="summary-secondary">{{ inst.publicIpMode === 'eip' ? 'EIP' : 'ECS 公网' }}</div>
</div>
<div class="summary-cell center-column">
<div class="summary-primary text-sm">{{ formatPublicBandwidth(inst) }}</div>
</div>
<div class="summary-cell center-column">
<div class="account-usage">
<div class="account-usage__meta single">
<span v-if="hasTrafficIssue(inst)">
{{ formatTrafficValue(inst.flow_used) }}
<span class="text-[#dc2626]">{{ inst.trafficScope === 'account' ? '账号聚合' : trafficIssueText(inst) }}</span>
</span>
<span v-else>{{ formatTrafficValue(inst.flow_used) }} / {{ inst.flow_total }} GB</span>
</div>
<div class="progress-track account-usage__bar">
<div class="progress-fill" :class="{ warning: inst.rate95 }"
:style="{ width: Math.min(inst.percentageOfUse, 100) + '%' }"></div>
</div>
<div v-if="hasTrafficIssue(inst)" class="mt-1 text-xs text-[#dc2626]">{{ trafficIssueText(inst) }}</div>
</div>
</div>
<div class="summary-cell center-column">
<div v-if="inst.operationLocked || inst.instanceStatus === 'Releasing'" class="text-xs font-semibold text-[#d97706]">
释放中,不可操作
</div>
<div v-else class="row-actions">
<button v-if="inst.instanceStatus === 'Stopped' || inst.instanceStatus === 'Unknown'" @click="controlInstance(inst.id, 'start')" class="success-button !px-3 !py-1.5 !text-xs" :disabled="isRefreshingInstance(inst.id)">启动</button>
<button v-if="inst.instanceStatus === 'Running'" @click="controlInstance(inst.id, 'stop')" class="warning-button !px-3 !py-1.5 !text-xs" :disabled="isRefreshingInstance(inst.id)">停止</button>
<button v-if="canReplaceIp(inst)" @click="replaceInstanceIp(inst.id)" class="ghost-button !px-3 !py-1.5 !text-xs" :disabled="isRefreshingInstance(inst.id)">更换 IP</button>
<button @click="deleteInstance(inst.id)" class="danger-button !px-3 !py-1.5 !text-xs" :disabled="isRefreshingInstance(inst.id)">释放</button>
</div>
</div>
</div>
<div v-if="filteredManagedInstances.length === 0" class="px-5 py-12 text-center text-sm text-[var(--muted)]">
暂无实例数据,点击"手动同步"获取最新实例。
</div>
</div>
</div>
</div>
<div class="mobile-manage-grid">
<article v-for="inst in filteredManagedInstances" :key="'manage-card-' + inst.id" class="metric-card">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-base font-bold">{{ inst.remark || inst.instanceName || inst.instanceId }}</p>
<p class="mt-1 font-mono text-xs text-[var(--muted)]">{{ inst.instanceId }}</p>
</div>
<span class="status-pill shrink-0" :class="statusClass(inst.instanceStatus)">{{ statusText(inst.instanceStatus) }}</span>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm">
<div><span class="text-[var(--muted)]">账号</span><p class="mt-1 text-xs">{{ inst.accountLabel }}</p></div>
<div><span class="text-[var(--muted)]">区域</span><p class="mt-1">{{ inst.regionName }}</p></div>
<div><span class="text-[var(--muted)]">地址</span><p class="mt-1 font-mono text-xs">{{ inst.publicIp || inst.privateIp || '-' }}</p><p class="mt-1 text-xs text-[var(--muted)]">{{ inst.publicIpMode === 'eip' ? 'EIP' : 'ECS 公网' }}</p></div>
<div>
<span class="text-[var(--muted)]">公网带宽峰值</span>
<p class="mt-1 text-sm font-semibold">{{ formatPublicBandwidth(inst) }}</p>
</div>
</div>
<div v-if="inst.operationLocked || inst.instanceStatus === 'Releasing'" class="mt-4 rounded-xl bg-amber-50 px-3 py-2 text-center text-xs font-semibold text-amber-700">
释放中,不可操作
</div>
<div v-else class="mt-4 flex gap-2">
<button v-if="inst.instanceStatus === 'Stopped' || inst.instanceStatus === 'Unknown'" @click="controlInstance(inst.id, 'start')" class="success-button flex-1" :disabled="isRefreshingInstance(inst.id)">启动</button>
<button v-if="inst.instanceStatus === 'Running'" @click="controlInstance(inst.id, 'stop')" class="warning-button flex-1" :disabled="isRefreshingInstance(inst.id)">停止</button>
<button v-if="canReplaceIp(inst)" @click="replaceInstanceIp(inst.id)" class="ghost-button flex-1" :disabled="isRefreshingInstance(inst.id)">更换 IP</button>
<button @click="deleteInstance(inst.id)" class="danger-button flex-1" :disabled="isRefreshingInstance(inst.id)">释放</button>
</div>
</article>
<div v-if="filteredManagedInstances.length === 0" class="metric-card">
<p class="text-center text-sm text-[var(--muted)]">暂无实例数据。</p>
</div>
</div>
</section>
<section v-if="currentMenu === 'accounts' && isAdmin" class="glass-panel section-panel">
<div class="section-head">
<div>
<p class="subtle-label">账号管理</p>
<h2 class="section-title">账号管理</h2>
<p class="mt-1 text-sm text-slate-500">账号通过弹窗维护。每次新增或编辑保存后,会自动同步该账号在所选区域下的全部实例,并把备注一并带到实例卡片中。</p>
</div>
<div class="top-action-row">
<button @click="openCreateAccountModal" class="ghost-button">新增账号</button>
</div>
</div>
<div class="table-shell desktop-visible" style="margin-top: 24px;">
<div class="bg-white/65 px-4 py-3 text-xs font-bold uppercase tracking-[0.18em] text-[var(--muted)]">
列表模式
</div>
<div>
<div class="account-row account-management-row summary-table-head">
<div>备注</div>
<div>区域</div>
<div>站点</div>
<div class="center-column">账号 CDT 流量</div>
<div class="center-column">消费情况</div>
<div class="center-column">操作</div>
</div>
<div v-for="(acc, idx) in config.Accounts" :key="'account-row-' + idx" class="account-row account-management-row">
<div class="summary-cell">
<div class="summary-primary">{{ acc.remark || '未命名账号' }}</div>
<div class="summary-secondary">实例会同步到这个备注名下</div>
</div>
<div class="summary-cell">
<div class="summary-primary">{{ getRegionName(acc.regionId) }}</div>
</div>
<div class="summary-cell">
<div class="summary-primary">{{ getSiteTypeLabel(acc.siteType) }}</div>
</div>
<div class="summary-cell center-column">
<div class="account-usage">
<div class="account-usage__meta single">
<span v-if="hasTrafficIssue(acc)" class="text-[#dc2626]">{{ trafficIssueText(acc) }}</span>
<span v-else>{{ formatTrafficValue(acc.usageUsed) }} / {{ acc.maxTraffic }} GB</span>
</div>
<div class="progress-track account-usage__bar">
<div class="progress-fill" :class="{ warning: (acc.usagePercent || 0) >= 95 }"
:style="{ width: Math.min(acc.usagePercent || 0, 100) + '%' }"></div>
</div>
<div v-if="hasTrafficIssue(acc)" class="mt-1 text-xs text-[#dc2626]">{{ trafficIssueText(acc) }}</div>
</div>
</div>
<div class="summary-cell center-column">
<div class="account-billing">
<template v-if="acc.billing && acc.billing.enabled && !acc.billing.error">
<div class="account-billing__line">
本月消费
<span class="account-billing__value">{{ formatCurrencyValue(acc.billing.monthly_cost, acc.billing.currency) }}</span>
</div>
<div class="account-billing__line">
账户余额
<span class="account-billing__value">{{ formatCurrencyValue(acc.billing.balance, acc.billing.currency) }}</span>
</div>
<div v-if="acc.billing.last_updated" class="account-billing__hint">{{ acc.billing.last_updated }} 更新</div>
<button @click.stop="openBillingDetails(acc)" class="account-billing__details-button">历史消费</button>
</template>
<div v-else-if="acc.billing && acc.billing.error" class="account-billing__error">{{ acc.billing.error }}</div>
<div v-else class="account-billing__hint">未启用 费用中心</div>
</div>
</div>
<div class="summary-cell center-column">
<div>
<div class="row-actions">
<button @click="syncAccount(idx)" class="ghost-button compact-button sync-button"
:class="getAccountSyncButtonClass(idx)"
:disabled="isSyncingAccount(idx)">
{{ getAccountSyncLabel(idx) }}
</button>
<button @click="openEditAccountModal(idx)" class="ghost-button compact-button">编辑</button>
<button @click="removeAccountRow(idx)" class="danger-button compact-button">删除</button>
</div>
<div v-if="getAccountSyncResult(idx)?.message" class="sync-note"
:class="{ error: !getAccountSyncResult(idx)?.success, success: getAccountSyncResult(idx)?.success }">
{{ getAccountSyncResult(idx)?.message }}
</div>
</div>
</div>
</div>
<div v-if="config.Accounts.length === 0" class="account-row">
<div class="summary-cell" style="grid-column: 1 / -1; text-align: center; color: var(--muted);">
还没有账号,点击“新增账号”开始配置。
</div>
</div>
</div>
</div>
<div class="mobile-stack" style="margin-top: 24px;">
<article v-for="(acc, idx) in config.Accounts" :key="'account-card-' + idx" class="metric-card">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold">{{ acc.remark || `账号 ${idx + 1}` }}</h3>
<span class="text-xs text-[var(--muted)]">{{ acc.instanceCount || 0 }} 台实例</span>
</div>
<div class="account-card-grid">
<div class="account-card-grid__meta">
<span class="account-card-grid__label">区域</span>
<strong class="account-card-grid__value">{{ getRegionName(acc.regionId) }}</strong>
</div>
<div class="account-card-grid__meta">
<span class="account-card-grid__label">站点</span>
<strong class="account-card-grid__value">{{ getSiteTypeLabel(acc.siteType) }}</strong>
</div>
<div class="account-card-grid__metric">
<span class="account-card-grid__label">账号 CDT 流量</span>
<strong v-if="hasTrafficIssue(acc)" class="account-card-grid__value text-[#dc2626]">{{ trafficIssueText(acc) }}</strong>
<strong v-else class="account-card-grid__value">{{ formatTrafficValue(acc.usageUsed) }} / {{ acc.maxTraffic }} GB</strong>
</div>
<div class="progress-track">
<div class="progress-fill" :class="{ warning: (acc.usagePercent || 0) >= 95 }"
:style="{ width: Math.min(acc.usagePercent || 0, 100) + '%' }"></div>
</div>
<div v-if="hasTrafficIssue(acc)" class="text-xs text-[#dc2626]">{{ trafficIssueText(acc) }}</div>
<div v-if="acc.billing && acc.billing.enabled && !acc.billing.error">
<div class="account-card-grid__metric">
<span class="account-card-grid__label">消费情况</span>
<strong class="account-card-grid__value">本月 {{ formatCurrencyValue(acc.billing.monthly_cost, acc.billing.currency) }} · 余额 {{ formatCurrencyValue(acc.billing.balance, acc.billing.currency) }}</strong>
</div>
<button @click="openBillingDetails(acc)" class="account-billing__details-button" style="margin-top: 8px;">查看历史消费</button>
</div>
<div v-else-if="acc.billing && acc.billing.error" class="account-billing__error">{{ acc.billing.error }}</div>
<div v-else class="account-billing__hint">消费情况:未启用 费用中心</div>
</div>
<div class="row-actions" style="margin-top: 16px;">
<button @click="syncAccount(idx)" class="ghost-button !px-3 !py-2 sync-button"
:class="getAccountSyncButtonClass(idx)"
:disabled="isSyncingAccount(idx)">
{{ getAccountSyncLabel(idx) }}
</button>
<button @click="openEditAccountModal(idx)" class="ghost-button !px-3 !py-2">编辑</button>
<button @click="removeAccountRow(idx)" class="danger-button !px-3 !py-2">删除</button>
</div>
<div v-if="getAccountSyncResult(idx)?.message" class="sync-note"
:class="{ error: !getAccountSyncResult(idx)?.success, success: getAccountSyncResult(idx)?.success }">
{{ getAccountSyncResult(idx)?.message }}
</div>
</article>
<div v-if="config.Accounts.length === 0" class="metric-card">
<p class="text-center text-sm text-[var(--muted)]">还没有账号,点击“新增账号”开始配置。</p>
</div>
</div>
</section>
<section v-if="currentMenu === 'settings' && isAdmin" class="glass-panel section-panel">
<div>
<p class="subtle-label">系统配置</p>
<h2 class="section-title">系统设置</h2>
</div>
<div class="settings-layout">
<nav class="settings-nav" aria-label="系统设置分类">
<button
v-for="(item, index) in settingsItems"
:key="item.key"
@click="currentSettingsTab = item.key"
class="settings-nav__button"
:class="{ active: currentSettingsTab === item.key }"
>
<span class="settings-nav__index">{{ String(index + 1).padStart(2, '0') }}</span>
<span>
<span class="settings-nav__label">{{ item.label }}</span>
<span class="settings-nav__hint">{{ item.description }}</span>
</span>
</button>
</nav>
<div class="settings-workspace">
<transition name="settings-panel" mode="out-in">
<div :key="currentSettingsTab" class="settings-grid">
<div v-if="currentSettingsTab === 'global'" class="metric-card space-y-4">
<div>
<p class="subtle-label">全局配置</p>
<h3 class="mt-1 text-lg font-bold">全局设置</h3>
</div>
<div class="space-y-3">
<div class="settings-brand-card rounded-[18px] border border-slate-200/70 bg-white/70 p-4">
<label class="mb-3 block text-sm font-semibold text-[#6f7787]">页面 Logo</label>
<div class="settings-brand-row flex flex-wrap items-center gap-4">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="页面 Logo 预览" class="brand-logo-preview">
<div v-else class="brand-logo-fallback !h-[52px] !w-[52px] !rounded-[14px]">C</div>
<div class="min-w-[220px] flex-1">
<div class="settings-brand-input-row">
<input v-model="config.AppBrand.logo_url" class="glass-input" placeholder="Logo 图片地址,或上传本地图片">
<div class="flex flex-wrap gap-2">
<label class="ghost-button cursor-pointer">
{{ uploadingLogo ? '上传中...' : '上传 Logo' }}
<input type="file" class="hidden" accept="image/png,image/jpeg,image/webp" :disabled="uploadingLogo" @change="uploadLogo">
</label>
<button v-if="config.AppBrand.logo_url" @click="clearLogo" type="button" class="danger-button">清除 Logo</button>
</div>
</div>
<p class="mt-2 text-xs leading-5 text-[var(--muted)]">支持 PNG、JPG、WebP上传文件小于 2MB。也可以直接填写图片 URL。</p>
</div>
</div>
</div>
<div class="settings-brand-card rounded-[18px] border border-slate-200/70 bg-white/70 p-4">
<label class="mb-2 block text-sm font-semibold text-[#6f7787]" for="app-brand-name">控制台名称</label>
<input id="app-brand-name" v-model.trim="config.AppBrand.name" class="glass-input" maxlength="40" placeholder="ECS 控制台">
<p class="mt-2 text-xs leading-5 text-[var(--muted)]">会同步应用到浏览器标题、顶部品牌、登录页和关于页面,最多 40 个字符。</p>
</div>
<div class="settings-brand-card rounded-[18px] border border-slate-200/70 bg-white/70 p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-[#1f2328]">数据备份与恢复</p>
<p class="mt-1 text-xs leading-5 text-[var(--muted)]">账号、设置、流量历史和 Passkey 会打包为加密文件。备份密码不会保存在系统中,请务必妥善保管。</p>
</div>
<span class="rounded-full bg-[#e5f4f2] px-3 py-1 text-xs font-semibold text-[#087f8c]">加密备份</span>
</div>
<div class="two-col-grid mt-4">
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]" for="backup-password">备份密码</label>
<input id="backup-password" v-model="backupPassword" type="password" class="glass-input" minlength="8" autocomplete="new-password" placeholder="至少 8 位">
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]" for="backup-file">恢复文件</label>
<input id="backup-file" type="file" class="glass-input" accept=".ecsbkp,application/octet-stream" @change="backupFile = $event.target.files?.[0] || null">
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button @click="createBackup" class="ghost-button" :disabled="backupBusy">{{ backupBusy ? '处理中...' : '下载备份' }}</button>
<button @click="restoreBackup" class="danger-button" :disabled="backupBusy || !backupFile">恢复备份</button>
</div>
<p class="mt-3 text-xs leading-5 text-amber-700">恢复会覆盖当前账号和设置,清除当前登录会话,并要求使用备份中的管理员凭据重新登录。</p>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">管理员密码</label>
<input v-model="config.admin_password" type="password" class="glass-input" autocomplete="new-password" placeholder="留空表示不修改,至少 6 位">
</div>
<div class="settings-brand-card rounded-[18px] border border-slate-200/70 bg-white/70 p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-[#1f2328]">Passkey 快速登录</p>
<p class="mt-1 text-xs leading-5 text-[var(--muted)]">
{{ passkeyCount > 0 ? `已设置 ${passkeyCount} 个 Passkey可在登录页免输入密码。` : '使用设备指纹、面容或安全密钥登录。首次设置仍需先完成密码登录。' }}
</p>
</div>
<button @click="registerPasskey" class="ghost-button" :disabled="passkeyBusy || !passkeySupported">
{{ passkeyBusy ? '处理中...' : passkeyCount > 0 ? '添加 Passkey' : '设置 Passkey' }}
</button>
</div>
<p v-if="!passkeySupported" class="mt-2 text-xs leading-5 text-amber-700">{{ passkeySupportHint }}</p>
<label class="switch-row mt-3 flex items-center justify-between gap-4 rounded-[16px] border border-slate-200/70 bg-white/70 px-3 py-3 text-sm">
<span>
<span class="block font-semibold text-[#1f2328]">允许密码登录</span>
<span class="mt-1 block text-xs leading-5 text-[var(--muted)]">
{{ config.password_login_enabled ? '开启后可使用管理员密码登录。' : '已关闭,登录时只能使用 Passkey。' }}
</span>
</span>
<span class="switch-control shrink-0">
<input type="checkbox" v-model="config.password_login_enabled" :disabled="passkeyCount === 0">
<span class="switch-slider"></span>
</span>
</label>
<p v-if="passkeyCount === 0" class="mt-2 text-xs leading-5 text-amber-700">请先注册至少一个 Passkey才能关闭密码登录。</p>
</div>
<div class="two-col-grid">
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">告警阈值 (%)</label>
<input v-model.number="config.traffic_threshold" type="number" class="glass-input" min="1" max="100">
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">实例同步频率</label>
<app-select
v-model="config.api_interval"
class="glass-select"
:options="[
{ value: 60, label: '1 分钟' },
{ value: 180, label: '3 分钟' },
{ value: 300, label: '5 分钟' },
{ value: 600, label: '10 分钟' },
{ value: 1800, label: '30 分钟' }
]"
/>
</div>
</div>
<div class="two-col-grid">
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">停机模式</label>
<app-select
v-model="config.shutdown_mode"
class="glass-select"
:options="[
{ value: 'KeepCharging', label: '普通停机' },
{ value: 'StopCharging', label: '节省停机' }
]"
/>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">阈值动作</label>
<app-select
v-model="config.threshold_action"
class="glass-select"
:options="[
{ value: 'stop_and_notify', label: '自动关机并告警' },
{ value: 'notify_only', label: '仅发送告警' }
]"
/>
</div>
</div>
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>抢占式实例保活</span>
<span class="switch-control">
<input type="checkbox" v-model="config.keep_alive">
<span class="switch-slider"></span>
</span>
</label>
<label class="switch-row flex items-center justify-between gap-4 rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>
<span class="block font-semibold text-[#1f2328]">每月 1 号自动开机</span>
<span class="mt-1 block text-xs leading-5 text-[#6f7787]">当实例处于已停机且未触发流量保护时,每月 1 号自动启动一次。</span>
</span>
<span class="switch-control shrink-0">
<input type="checkbox" v-model="config.monthly_auto_start">
<span class="switch-slider"></span>
</span>
</label>
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>成本分析(费用中心)</span>
<span class="switch-control">
<input type="checkbox" v-model="config.enable_billing">
<span class="switch-slider"></span>
</span>
</label>
</div>
</div>
<div v-if="currentSettingsTab === 'ddns'" class="metric-card">
<div>
<p class="subtle-label">DDNS</p>
<h3 class="mt-1 text-lg font-bold">动态域名解析</h3>
<p class="mt-2 text-sm leading-6 text-[#6f7787]">
默认使用 Cloudflare。系统会在 ECS 创建、实例启动后和手动同步时,把公网 IP 写入 A 记录。
</p>
</div>
<div class="mt-5 space-y-3">
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>启用 DDNS</span>
<span class="switch-control">
<input type="checkbox" v-model="config.Ddns.enabled">
<span class="switch-slider"></span>
</span>
</label>
<input type="hidden" v-model="config.Ddns.provider">
<div class="ddns-provider-card">
<div class="ddns-provider-card__badge" aria-hidden="true">CF</div>
<div>
<div class="ddns-provider-card__title">
<span>Cloudflare</span>
<span class="ddns-provider-card__tag">当前提供商</span>
</div>
<div class="ddns-provider-card__hint">当前支持 Cloudflare后续可扩展其他 DNS 服务商。</div>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">Cloudflare API Token</label>
<input v-model="config.Ddns.cloudflare.token" type="password" class="glass-input" placeholder="需要 Zone DNS Edit 权限">
</div>
<div class="two-col-grid">
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">根域名</label>
<input v-model="config.Ddns.domain" class="glass-input" placeholder="例如 example.com">
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">Zone ID可选</label>
<input v-model="config.Ddns.cloudflare.zone_id" class="glass-input" placeholder="留空时自动查询">
</div>
</div>
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>开启 Cloudflare 代理</span>
<span class="switch-control">
<input type="checkbox" v-model="config.Ddns.cloudflare.proxied">
<span class="switch-slider"></span>
</span>
</label>
<p class="text-xs leading-6 text-[var(--muted)]">
必填API Token 和根域名。Zone ID 可不填,系统会根据根域名自动查询。命名规则:单机器使用“账号备注.根域名”;同账号多机器使用“账号备注-实例备注或实例短 ID.根域名”。
</p>
</div>
</div>
<div v-if="currentSettingsTab === 'notifications'" class="metric-card">
<div class="tab-pill-group">
<button @click="currentNotifyTab = 'telegram'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'telegram' }">Telegram</button>
<button @click="currentNotifyTab = 'email'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'email' }">邮件</button>
<button @click="currentNotifyTab = 'webhook'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'webhook' }">接口回调</button>
</div>
<div class="daily-summary-card">
<label class="switch-row flex items-center justify-between rounded-[12px] border px-3 py-2 text-sm">
<span>
<span class="daily-summary-card__title">每日流量摘要</span>
<span class="daily-summary-card__hint">CMS 推送昨日实例增量CDT 推送当前周期累计用量。</span>
</span>
<span class="switch-control shrink-0">
<input type="checkbox" v-model="config.Notification.daily_enabled">
<span class="switch-slider"></span>
</span>
</label>
<div class="daily-summary-card__time">
<span>推送时间</span>
<time-picker v-model="config.Notification.daily_time" />
</div>
</div>
<div v-if="currentNotifyTab === 'email'" class="mt-5 space-y-3">
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>启用邮件推送</span>
<span class="switch-control">
<input type="checkbox" v-model="config.Notification.email_enabled">
<span class="switch-slider"></span>
</span>
</label>
<input v-model="config.Notification.email" class="glass-input" placeholder="接收邮箱">
<div class="two-col-grid">
<input v-model="config.Notification.host" class="glass-input" placeholder="邮件服务器地址">
<input v-model.number="config.Notification.port" type="number" class="glass-input" placeholder="端口">
</div>
<div class="grid gap-3 md:grid-cols-2">
<input v-model="config.Notification.username" class="glass-input" placeholder="邮箱账号">
<input v-model="config.Notification.password" type="password" class="glass-input" placeholder="邮箱密码或授权码">
</div>
<app-select
v-model="config.Notification.secure"
class="glass-select"
:options="[
{ value: 'ssl', label: '安全加密' },
{ value: 'tls', label: '传输加密' },
{ value: '', label: '无加密' }
]"
/>
<button @click="sendTestEmail" class="solid-button" :disabled="sendingEmail">{{ sendingEmail ? '发送中...' : '测试邮件通知' }}</button>
</div>
<div v-if="currentNotifyTab === 'telegram'" class="mt-5 space-y-3">
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>启用 Telegram 通知</span>
<span class="switch-control">
<input type="checkbox" v-model="config.Notification.telegram.enabled">
<span class="switch-slider"></span>
</span>
</label>
<input v-model="config.Notification.telegram.token" class="glass-input" placeholder="机器人令牌">
<input v-model="config.Notification.telegram.chat_id" class="glass-input" placeholder="接收会话编号">
<app-select
v-model="config.Notification.telegram.proxy_type"
class="glass-select"
:options="[
{ value: 'none', label: '不使用代理' },
{ value: 'custom', label: '自定义转发地址' },
{ value: 'socks5', label: '代理服务器' }
]"
/>
<input v-if="config.Notification.telegram.proxy_type === 'custom'" v-model="config.Notification.telegram.proxy_url" class="glass-input" placeholder="自定义转发地址">
<div v-if="config.Notification.telegram.proxy_type === 'socks5'" class="two-col-grid">
<input v-model="config.Notification.telegram.proxy_ip" class="glass-input" placeholder="代理地址">
<input v-model="config.Notification.telegram.proxy_port" class="glass-input" placeholder="代理端口">
<input v-model="config.Notification.telegram.proxy_user" class="glass-input" placeholder="代理账号">
<input v-model="config.Notification.telegram.proxy_pass" type="password" class="glass-input" placeholder="代理密码">
</div>
<button @click="sendTestTelegram" class="solid-button" :disabled="testingTelegram">{{ testingTelegram ? '发送中...' : '测试 Telegram' }}</button>
</div>
<div v-if="currentNotifyTab === 'webhook'" class="mt-5 space-y-3">
<label class="switch-row flex items-center justify-between rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<span>启用接口回调</span>
<span class="switch-control">
<input type="checkbox" v-model="config.Notification.webhook.enabled">
<span class="switch-slider"></span>
</span>
</label>
<input v-model="config.Notification.webhook.url" class="glass-input" placeholder="接口回调地址">
<div class="two-col-grid">
<app-select
v-model="config.Notification.webhook.method"
class="glass-select"
:options="[
{ value: 'GET', label: '读取请求' },
{ value: 'POST', label: '发送请求' }
]"
/>
<app-select
v-model="config.Notification.webhook.request_type"
class="glass-select"
:options="[
{ value: 'JSON', label: '数据格式' },
{ value: 'FORM', label: '表单格式' }
]"
/>
</div>
<textarea v-model="config.Notification.webhook.headers" class="glass-textarea" placeholder='请求头配置,例如 {"Authorization":"Bearer xxx"}'></textarea>
<textarea v-model="config.Notification.webhook.body" class="glass-textarea" placeholder='消息模板,例如 {"title":"#TITLE#","msg":"#MSG#"}'></textarea>
<button @click="sendTestWebhook" class="solid-button" :disabled="testingWebhook">{{ testingWebhook ? '发送中...' : '发送测试消息' }}</button>
</div>
</div>
</transition>
</div>
</div>
</section>
<section v-if="currentMenu === 'logs' && isAdmin" class="glass-panel section-panel">
<div class="section-head">
<div>
<p class="subtle-label">系统日志</p>
<h2 class="section-title">系统日志</h2>
</div>
<div class="tab-pill-group">
<button @click="currentLogTab = 'action'; fetchLogs()" class="ghost-button" :class="{ 'text-dark-button': currentLogTab === 'action' }">动作日志</button>
<button @click="currentLogTab = 'heartbeat'; fetchLogs()" class="ghost-button" :class="{ 'text-dark-button': currentLogTab === 'heartbeat' }">心跳日志</button>
<button @click="clearLogs" class="danger-button">清空</button>
</div>
</div>
<div class="mt-5 space-y-3">
<article v-for="log in systemLogs" :key="log.id || log.time_str + log.message" class="metric-card log-card">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="status-pill"
:class="currentLogTab === 'action'
? (log.type === 'warning' ? 'status-transition' : log.type === 'error' ? 'status-stopped' : 'status-running')
: (log.type === 'error' ? 'status-stopped' : log.type === 'warning' ? 'status-transition' : 'status-running')">
{{ logTypeLabel(log.type) }}
</span>
<span class="text-xs text-[var(--muted)] font-mono">{{ log.time_str }}</span>
</div>
</div>
<p class="mt-3 font-mono text-sm leading-6">{{ log.message }}</p>
</article>
<div v-if="systemLogs.length === 0" class="metric-card">
<p class="text-center text-sm text-[var(--muted)]">当前没有日志记录。</p>
</div>
</div>
</section>
<section v-if="currentMenu === 'about' && isAdmin" class="glass-panel section-panel about-section">
<div class="about-hero">
<div class="about-hero__mark" aria-hidden="true">06</div>
<div>
<p class="subtle-label">项目说明</p>
<h2 class="about-hero__title">关于 {{ appBrandName }}</h2>
<p class="about-hero__copy">阿里云 ECS 实例、流量与自动化控制台。</p>
</div>
</div>
<div class="about-grid">
<div class="metric-card update-center-card" :class="{ 'has-update': hasVersionUpdate }">
<div class="update-center-card__head">
<div class="update-center-card__intro">
<div class="update-center-card__eyebrow">
<p class="subtle-label">版本中心</p>
<span v-if="hasVersionUpdate" class="update-center-card__state update-center-card__state--new">
<span class="update-center-card__state-dot"></span>
发现新版本
</span>
<span v-else-if="updateInfo.latest?.version" class="update-center-card__state update-center-card__state--current">
<span class="update-center-card__state-dot"></span>
已是最新版本
</span>
</div>
<div class="update-center-card__title-row">
<h3 class="text-lg font-bold">在线更新</h3>
</div>
</div>
<a :href="updateInfo.repository_url || 'https://github.com/Kori1c/ecs-controller'" target="_blank" rel="noopener noreferrer" class="update-center-card__link" aria-label="打开 GitHub 项目">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .5a12 12 0 0 0-3.79 23.39c.6.11.82-.26.82-.58v-2.25c-3.34.73-4.04-1.61-4.04-1.61-.55-1.4-1.34-1.77-1.34-1.77-1.09-.75.08-.74.08-.74 1.2.09 1.84 1.23 1.84 1.23 1.07 1.83 2.8 1.3 3.48.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.34-5.47-5.95 0-1.31.47-2.38 1.23-3.22-.12-.3-.53-1.52.12-3.17 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.29-1.23 3.29-1.23.66 1.65.25 2.87.13 3.17.76.84 1.23 1.91 1.23 3.22 0 4.62-2.81 5.64-5.49 5.94.43.37.81 1.1.81 2.22v3.29c0 .32.22.69.83.57A12 12 0 0 0 12 .5Z"/></svg>
<span>GitHub</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M7 17 17 7M8 7h9v9"/></svg>
</a>
</div>
<div class="update-center-card__body">
<div class="update-release" aria-label="版本对比">
<div class="update-release__version">
<span class="update-release__label">当前版本</span>
<div class="update-release__value-row">
<strong class="update-release__value">{{ updateInfo.current_version || 'dev' }}</strong>
</div>
</div>
<div class="update-release__connector" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h15M13 6l6 6-6 6"/></svg>
</div>
<div class="update-release__version" :class="{ 'latest-version': hasVersionUpdate }">
<span class="update-release__label">最新版本</span>
<div class="update-release__value-row">
<strong class="update-release__value">{{ updateInfo.latest?.version || '未检查' }}</strong>
<span v-if="hasAvailableUpdate" class="update-release__badge">可更新</span>
</div>
</div>
</div>
<div class="update-center-card__actions">
<button @click="handleUpdateAction" :class="hasAvailableUpdate ? 'solid-button' : 'ghost-button'" :disabled="checkingUpdate || startingUpdate || updateScreenVisible || updateState.status === 'queued' || updateState.status === 'running'">
{{ checkingUpdate ? '检查中...' : startingUpdate ? '提交中...' : hasAvailableUpdate ? '立即更新' : '检查更新' }}
</button>
</div>
</div>
<div v-if="updateState.status === 'error'" class="update-center-card__notice is-error">
<span class="update-center-card__notice-icon">!</span>
<div>
<strong>更新失败</strong>
<p>{{ updateState.message || '请查看系统日志获取详细信息。' }}</p>
</div>
</div>
<div v-if="updateInfo.configured === false || (updateInfo.message && updateInfo.configured !== false)" class="update-center-card__notice" :class="{ 'is-warning': updateInfo.configured === false }">
<span class="update-center-card__notice-icon">{{ updateInfo.configured === false ? '!' : 'i' }}</span>
<div>
<strong>{{ updateInfo.configured === false ? '当前部署不支持在线更新' : hasAvailableUpdate ? '已检测到可用镜像' : '检查结果' }}</strong>
<p>{{ updateInfo.configured === false ? '请使用 install.sh 更新;在线更新只会拉取已发布的 Docker 镜像。' : updateInfo.message }}</p>
</div>
</div>
</div>
<aside class="metric-card about-donation-card">
<div class="about-donation-card__head">
<div>
<p class="subtle-label">支持项目</p>
<h3 class="mt-1 text-lg font-bold">打赏支持</h3>
<p class="about-donation-card__copy">如果这个工具对你有帮助,欢迎赏杯咖啡。</p>
</div>
<div class="about-donation-card__badge" aria-hidden="true"></div>
</div>
<img src="/static/donation-code.jpg" alt="项目赞赏码" class="about-donation-card__code">
<div class="about-donation-card__wallets">
<div><strong>USDT-TRC20</strong><br><code>TMBmXngHcKtNo9nLxTCJcGTfNutsbb9rp3</code></div>
<div><strong>USDT-Polygon / BSC / ERC20 / ArbitrumOne</strong><br><code>0x846ad38ed7159aee70b0ebd8ec39ad2d7b32835b</code></div>
</div>
</aside>
</div>
</section>
</template>
</main>
</div>
<nav v-if="initialized && !criticalError && isAdmin" class="mobile-bottom-nav" :class="{ 'is-hidden': mobileNavHidden }">
<button
v-for="item in menuItems"
:key="'mobile-nav-' + item.key"
@click="currentMenu = item.key"
class="mobile-bottom-nav__button"
:class="{ active: currentMenu === item.key }"
>
<span v-html="item.icon"></span>
<span>{{ item.label }}</span>
</button>
</nav>
</div>
<transition name="settings-save">
<div v-if="settingsDirty" class="settings-save-float">
<span class="settings-save-float__hint">系统设置有未保存改动</span>
<button @click="saveConfig" class="solid-button">保存系统设置</button>
</div>
</transition>
<transition-group name="toast" tag="div" class="toast-stack">
<div v-for="toast in toasts" :key="toast.id" class="toast-card" :class="toast.type">
<div class="toast-card__status">
<span class="toast-status-dot"></span>
<div class="toast-title">{{ toast.type === 'error' ? '错误' : toast.type === 'success' ? '完成' : '提示' }}</div>
</div>
<div class="toast-message">{{ toast.message }}</div>
</div>
</transition-group>
<div v-if="dialogState.open" class="dialog-overlay">
<div class="glass-panel dialog-card">
<p class="subtle-label">{{ dialogState.mode === 'confirm' ? '操作确认' : '系统提示' }}</p>
<h2 class="mt-2 text-2xl font-black">{{ dialogState.title }}</h2>
<p class="mt-4 text-sm leading-7 text-[#475569]">{{ dialogState.message }}</p>
<div class="dialog-actions">
<button v-if="dialogState.mode === 'confirm'" @click="resolveDialog(false)" class="ghost-button">
{{ dialogState.cancelLabel }}
</button>
<button @click="resolveDialog(true)" class="solid-button">
{{ dialogState.confirmLabel }}
</button>
</div>
</div>
</div>
<transition name="online-update">
<div v-if="updateScreenVisible" class="online-update-overlay" role="dialog" aria-modal="true" aria-labelledby="online-update-title" aria-live="polite">
<div class="online-update-card">
<div class="online-update-card__top">
<span class="online-update-card__mark" aria-hidden="true"></span>
<div>
<p class="online-update-card__eyebrow">在线更新</p>
<h2 id="online-update-title" class="online-update-card__title">
{{ updateScreenCompleted ? '更新完成,正在刷新页面' : '正在更新' }}
</h2>
<p class="online-update-card__copy">
{{ updateScreenCompleted ? '新版本已就绪,请稍候,页面将自动重新加载。' : '正在拉取预构建 Docker 镜像并重启服务,请不要关闭页面。' }}
</p>
</div>
</div>
<div class="online-update-card__progress-wrap">
<div class="online-update-card__progress" aria-hidden="true">
<span :style="{ width: `${updateScreenProgress}%` }"></span>
</div>
<div class="online-update-card__progress-meta">
<strong>{{ updateScreenProgress }}%</strong>
<span>{{ updateScreenCompleted ? '正在重新加载页面' : updateScreenWaiting ? '正在等待更新服务完成' : '请稍候' }}</span>
</div>
</div>
<div class="online-update-card__versions" aria-label="版本信息">
<div class="online-update-card__version">
<span class="online-update-card__version-label">当前版本</span>
<strong>{{ updateInfo.current_version || 'dev' }}</strong>
</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6"/></svg>
<div class="online-update-card__version">
<span class="online-update-card__version-label">目标版本</span>
<strong>{{ updateInfo.latest?.version || '最新版本' }}</strong>
</div>
</div>
<p class="online-update-card__footnote">更新只会拉取已发布的 Docker 镜像,不会在当前机器构建镜像。</p>
</div>
</div>
</transition>
<div v-if="criticalError" class="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-4 backdrop-blur-sm">
<div class="glass-panel w-full max-w-lg rounded-[34px] p-8">
<p class="subtle-label">初始化异常</p>
<h2 class="mt-2 text-2xl font-black">系统初始化错误</h2>
<p class="mt-4 rounded-[20px] bg-red-50/90 px-4 py-4 font-mono text-sm text-red-700">{{ criticalError }}</p>
<div class="mt-6 flex justify-end">
<button @click="reloadPage" class="solid-button">重试</button>
</div>
</div>
</div>
<div v-if="showLoginModal && isAdmin" class="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-4 backdrop-blur-sm">
<div class="glass-panel w-full max-w-md rounded-[34px] p-8">
<div class="auth-brand">
<div class="auth-brand__logo">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="阿里云 Logo" class="brand-logo">
<div v-else class="brand-logo-fallback">C</div>
</div>
<div>
<p class="auth-brand__eyebrow">{{ appBrandName }}</p>
<h2 class="auth-brand__title">管理员登录</h2>
</div>
</div>
<p class="auth-brand__lead">验证管理员身份后继续进入控制台。</p>
<input v-model="passwordInput" type="password" class="glass-input mt-6" placeholder="请输入管理员密码">
<div class="mt-5 flex gap-3">
<button @click="showLoginModal = false" class="ghost-button flex-1">取消</button>
<button @click="performLogin" class="solid-button flex-1">登录</button>
</div>
</div>
</div>
<div v-if="showAccountModal" class="account-modal-overlay">
<div class="glass-panel account-modal">
<div class="section-head">
<div>
<p class="subtle-label">账号信息</p>
<h2 class="section-title">{{ accountModalMode === 'edit' ? '编辑账号' : '新增账号' }}</h2>
<p class="mt-1 text-sm text-slate-500">填写账号信息后会立即保存并同步当前区域下的实例。</p>
</div>
<button @click="closeAccountModal" class="ghost-button compact-button">关闭</button>
</div>
<div class="account-modal__body">
<div class="modal-form-grid">
<div class="modal-field-full">
<label class="field-label">备注名</label>
<input v-model="accountDraft.remark" class="glass-input" placeholder="例如:香港节点组">
</div>
<div>
<label class="field-label">AK ID</label>
<input v-model="accountDraft.AccessKeyId" class="glass-input" placeholder="请输入AK ID">
</div>
<div>
<label class="field-label">AK Secret</label>
<input v-model="accountDraft.AccessKeySecret" type="password" class="glass-input" placeholder="请输入AK Secret">
</div>
<div>
<label class="field-label">区域</label>
<app-select
v-model="accountDraft.regionId"
class="glass-select"
placeholder="请选择区域"
:options="[{ value: '', label: '请选择区域' }, ...aliyunRegions.map(region => ({ value: region.id, label: region.name }))]"
/>
</div>
<div>
<label class="field-label">站点</label>
<app-select
v-model="accountDraft.siteType"
class="glass-select"
:options="[
{ value: 'china', label: '中国站' },
{ value: 'international', label: '国际站' }
]"
/>
</div>
<div>
<label class="field-label">账号可用流量GB</label>
<input v-model.number="accountDraft.maxTraffic" type="number" min="1" class="glass-input" placeholder="200">
</div>
<div class="modal-field-full schedule-card">
<div class="schedule-card__header">
<div>
<label class="field-label !mb-1">定时开关机</label>
<p class="schedule-card__hint">支持每天按计划时间自动开机、自动停机。定时停机将沿用系统设置里的停机方式。</p>
</div>
<span class="switch-control">
<input v-model="accountDraft.scheduleEnabled" type="checkbox">
<span class="switch-slider"></span>
</span>
</div>
<transition name="schedule-options">
<div v-if="accountDraft.scheduleEnabled" class="schedule-options mt-4 grid gap-4 md:grid-cols-2">
<div class="schedule-option">
<div class="schedule-option__top">
<label class="field-label !mb-0">定时开机</label>
<span class="switch-control">
<input v-model="accountDraft.scheduleStartEnabled" type="checkbox">
<span class="switch-slider"></span>
</span>
</div>
<time-picker
v-model="accountDraft.startTime"
placeholder="请选择开机时间"
:disabled="!accountDraft.scheduleStartEnabled"
/>
</div>
<div class="schedule-option">
<div class="schedule-option__top">
<label class="field-label !mb-0">定时停机</label>
<span class="switch-control">
<input v-model="accountDraft.scheduleStopEnabled" type="checkbox">
<span class="switch-slider"></span>
</span>
</div>
<time-picker
v-model="accountDraft.stopTime"
placeholder="请选择停机时间"
:disabled="!accountDraft.scheduleStopEnabled"
/>
</div>
</div>
</transition>
</div>
</div>
<div v-if="accountDraftTestResult" class="test-result-card"
:class="accountDraftTestResult.monitorStatus === 'warning' || !accountDraftTestResult.success ? 'warning' : 'success'">
<div class="flex items-center justify-between gap-3">
<div>
<p class="test-result-title text-sm font-bold">账号测试结果</p>
<p class="test-result-message mt-1 text-xs">{{ accountDraftTestResult.message }}</p>
</div>
<span class="test-result-badge"
:class="{ warning: accountDraftTestResult.monitorStatus === 'warning' || !accountDraftTestResult.success }">
{{ accountDraftTestResult.monitorStatus === 'warning' || !accountDraftTestResult.success ? '需处理' : '通过' }}
</span>
</div>
<div class="test-result-line">
<strong>ECS 接口</strong>
<span>已接通</span>
</div>
<div class="test-result-line">
<strong>云监控接口</strong>
<span :class="{ 'warning-text': accountDraftTestResult.monitorStatus === 'warning' }">
{{ accountDraftTestResult.monitorMessage || '未测试' }}
</span>
</div>
<div class="test-result-line">
<strong>当前区域实例数</strong>
<span>{{ accountDraftTestResult.instanceCount ?? 0 }} 台</span>
</div>
</div>
</div>
<div class="top-action-row" style="justify-content: flex-end; margin-top: 24px;">
<button @click="closeAccountModal" class="ghost-button">取消</button>
<button @click="testAccountDraft" class="ghost-button" :disabled="testingAccountDraft || savingAccountModal">
{{ testingAccountDraft ? '测试中...' : '测试账号' }}
</button>
<button @click="saveAccountModal" class="solid-button" :disabled="savingAccountModal">
{{ savingAccountModal ? '保存中...' : '保存账号' }}
</button>
</div>
</div>
</div>
<div v-if="showBillingDetailsModal" class="account-modal-overlay" @click.self="closeBillingDetails">
<div class="glass-panel account-modal billing-modal">
<div class="section-head">
<div>
<div>
<p class="subtle-label">费用中心</p>
<button v-if="selectedBillingDate" @click="backToBillingHistory" class="billing-details-back">← 返回历史消费</button>
<h2 class="section-title">
<template v-if="selectedBillingDate && selectedBillingDay">
{{ formatBillingDate(selectedBillingDate) }}<span class="billing-details-title__amount">消费金额:{{ formatBillingAmount(selectedBillingDay.total, billingDetails.currency, 6) }}</span>
</template>
<template v-else>历史消费</template>
</h2>
<p class="mt-1 text-sm text-slate-500">{{ billingDetails.title }} · 云端账单可能存在延迟</p>
</div>
</div>
<button @click="closeBillingDetails" class="ghost-button compact-button">关闭</button>
</div>
<div class="billing-content-stage">
<div class="billing-content-view">
<div v-if="!selectedBillingDate" class="billing-details-summary">
<div>
<div class="billing-details-summary__label">近 {{ billingDetails.days }} 日消费</div>
<div class="billing-details-summary__value">{{ formatBillingAmount(billingDetails.total, billingDetails.currency, 6) }}</div>
</div>
<div class="billing-details-summary__meta">
<div>{{ billingDetails.from }} 至 {{ billingDetails.to }}</div>
<div v-if="billingDetails.last_updated">{{ billingDetails.last_updated }} 更新</div>
</div>
</div>
<div v-if="billingDetailsLoading" class="billing-details-empty">正在查询阿里云账单...</div>
<div v-else-if="billingDetailsError" class="billing-details-empty text-red-600">{{ billingDetailsError }}</div>
<div v-else-if="billingDetails.items.length === 0" class="billing-details-empty">阿里云暂未返回这段时间的账单明细。</div>
<div v-else-if="!selectedBillingDate" class="billing-history-list">
<button v-for="(day, index) in billingHistoryDays" :key="`billing-history-${day.date}`" class="billing-history-row" :style="{ '--billing-enter-delay': `${Math.min(index, 7) * 20}ms` }" @click="openBillingDay(day.date)">
<div class="billing-history-row__date">
<div class="billing-history-row__date-label">{{ formatBillingDate(day.date) }}</div>
<div class="billing-history-row__date-value">{{ day.date }}</div>
</div>
<div class="billing-history-row__products" :title="day.productSummary">{{ day.productSummary }}</div>
<div class="billing-history-row__count">{{ day.count }} 项</div>
<div class="billing-history-row__amount">{{ formatBillingAmount(day.total, billingDetails.currency, 6) }}</div>
<div class="billing-history-row__arrow">查看 →</div>
</button>
</div>
<div v-else class="billing-details-scroll">
<table class="billing-details-table">
<thead>
<tr>
<th>计费项目</th>
<th title="账单累计用量与当前资源配置分开呈现,避免将计费单位误认为容量或数量">计费口径</th>
<th>金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in selectedBillingDay?.items || []" :key="`billing-detail-${selectedBillingDate}-${index}`" :style="{ '--billing-enter-delay': `${Math.min(index, 8) * 16}ms` }">
<td>
<div class="billing-details-table__product">{{ item.product_name }}</div>
<div v-if="item.billing_item" class="billing-details-table__item">计费项目:{{ item.billing_item }}</div>
<div v-if="item.product_detail && item.product_detail !== item.product_name" class="billing-details-table__code">明细:{{ item.product_detail }}</div>
<div v-if="item.instance_id" class="billing-details-table__instance">账单资源:{{ item.instance_id }}</div>
</td>
<td class="billing-details-table__usage">
<div class="billing-details-table__usage-main">{{ billingUsagePresentation(item).primary }}</div>
<div v-if="billingUsagePresentation(item).current" class="billing-details-table__usage-current">{{ billingUsagePresentation(item).current }}</div>
<div v-if="billingUsagePresentation(item).billing" class="billing-details-table__usage-billing">{{ billingUsagePresentation(item).billing }}</div>
<div v-if="billingUsagePresentation(item).config" class="billing-details-table__usage-config">{{ billingUsagePresentation(item).config }}</div>
</td>
<td class="billing-details-table__amount">{{ formatBillingAmount(item.amount, item.currency || billingDetails.currency) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="top-action-row" style="justify-content: flex-end; margin-top: 18px;">
<button @click="reloadBillingDetails" class="ghost-button" :disabled="billingDetailsLoading">
{{ billingDetailsLoading ? '查询中...' : '刷新历史' }}
</button>
</div>
</div>
</div>
<div v-if="showCreateEcsModal" class="account-modal-overlay">
<div class="glass-panel account-modal create-modal">
<div class="section-head">
<div>
<p class="subtle-label">快速创建 ECS</p>
<h2 class="section-title">一键创建 ECS</h2>
<p class="mt-1 text-sm text-slate-500">只填写规格和系统,网络、安全组、系统盘、按流量公网与同步流程由系统自动处理。</p>
</div>
<button @click="closeCreateEcsModal" class="ghost-button compact-button">关闭</button>
</div>
<div v-if="!ecsCreatePreview" class="modal-form-grid">
<div>
<label class="field-label">账号 / 区域</label>
<app-select
v-model="ecsCreateDraft.accountGroupKey"
class="glass-select"
placeholder="请选择账号"
:options="accountOptions"
/>
<p class="mt-2 text-xs text-slate-500">创建区域跟随账号配置,避免跨区域创建后无法归属到当前账号组。</p>
</div>
<div>
<label class="field-label">实例规格</label>
<input v-model="ecsCreateDraft.instanceType" class="glass-input" placeholder="ecs.e-c4m1.large">
</div>
<div>
<label class="field-label">系统</label>
<app-select
v-model="ecsCreateDraft.osKey"
class="glass-select"
:options="ecsOsOptions"
/>
</div>
<div>
<label class="field-label">公网 IP 类型</label>
<app-select
v-model="ecsCreateDraft.publicIpMode"
class="glass-select"
:options="[
{ value: 'ecs_public_ip', label: 'ECS 普通公网 IP' },
{ value: 'eip', label: 'EIP 弹性公网 IP' }
]"
/>
<p class="mt-2 text-xs text-slate-500">EIP 支持后续一键更换 IP停机不释放释放 ECS 时自动释放系统创建的 EIP。</p>
</div>
<div>
<label class="field-label">硬盘类型</label>
<app-select
v-model="ecsCreateDraft.systemDiskCategory"
class="glass-select"
:disabled="ecsDiskLoading || ecsDiskCategoryOptions.length === 0"
:options="ecsDiskCategorySelectOptions"
/>
<p class="mt-2 text-xs text-slate-500">
{{ ecsDiskLoading ? '正在从阿里云获取当前规格支持的硬盘类型...' : ecsDiskHint }}
</p>
</div>
<div>
<label class="field-label">系统盘大小GB</label>
<input v-model.number="ecsCreateDraft.systemDiskSize" type="number" min="1" class="glass-input" placeholder="20">
</div>
<div>
<label class="field-label">实例备注 / 名称</label>
<input v-model="ecsCreateDraft.instanceName" class="glass-input" placeholder="launch-20260415-2010">
</div>
</div>
<div v-else>
<div class="summary-block">
<h4>基础配置</h4>
<div class="summary-list">
<div><span>账号</span><strong>{{ ecsCreatePreview.summary.account.label }}</strong></div>
<div><span>区域</span><strong>{{ getRegionName(ecsCreatePreview.summary.regionId) }}</strong></div>
<div><span>可用区</span><strong>{{ ecsCreatePreview.summary.zoneId }}</strong></div>
<div><span>实例规格</span><strong>{{ ecsCreatePreview.summary.instanceType }}</strong></div>
<div><span>系统</span><strong>{{ ecsCreatePreview.summary.osLabel }}</strong></div>
<div><span>镜像编号</span><strong>{{ ecsCreatePreview.summary.imageId }}</strong></div>
<div><span>实例名称</span><strong>{{ ecsCreatePreview.summary.instanceName }}</strong></div>
<div><span>登录用户</span><strong>{{ ecsCreatePreview.summary.loginUser }}</strong></div>
</div>
</div>
<div class="summary-block">
<h4>计费与资源</h4>
<div class="summary-list">
<div><span>实例计费</span><strong>按量付费</strong></div>
<div><span>公网计费</span><strong>按使用流量计费</strong></div>
<div><span>公网 IP 类型</span><strong>{{ ecsCreatePreview.summary.publicIpModeLabel }}</strong></div>
<div><span>流量抵扣服务</span><strong>兼容方式创建</strong></div>
<div><span>公网带宽峰值</span><strong>{{ ecsCreatePreview.summary.internetMaxBandwidthOut }} Mbps起自动降级</strong></div>
<div><span>系统盘</span><strong>{{ ecsCreatePreview.summary.systemDisk.size }} GB / {{ diskCategoryText(ecsCreatePreview.summary.systemDisk.category) }}</strong></div>
<div><span>系统盘范围</span><strong>{{ ecsCreatePreview.summary.systemDisk.min }} - {{ ecsCreatePreview.summary.systemDisk.max }} {{ diskUnitText(ecsCreatePreview.summary.systemDisk.unit) }}</strong></div>
<div><span>文件备份</span><strong>{{ ecsCreatePreview.summary.backupEnabled ? '启用' : '不启用' }}</strong></div>
<div><span>费用</span><strong>{{ ecsCreatePreview.pricing.message }}</strong></div>
</div>
</div>
<div class="summary-block">
<h4>自动网络配置</h4>
<div class="summary-list">
<div><span>专有网络</span><strong>{{ ecsCreatePreview.summary.network.vpc.name }} / {{ ecsCreatePreview.summary.network.vpc.cidr }}</strong></div>
<div><span>交换机</span><strong>{{ ecsCreatePreview.summary.network.vswitch.name }} / {{ ecsCreatePreview.summary.network.vswitch.cidr }}</strong></div>
<div><span>安全组</span><strong>{{ ecsCreatePreview.summary.network.securityGroup.name }}</strong></div>
<div><span>安全组规则</span><strong>{{ ecsCreatePreview.summary.network.securityGroup.rules.join('') }}</strong></div>
</div>
<div class="warning-list">
<div v-for="warning in ecsCreatePreview.warnings" :key="warning">· {{ warning }}</div>
</div>
</div>
<label class="confirm-check">
<input v-model="ecsCreateConfirmed" type="checkbox">
<span>我确认以上配置和可能产生的按量费用,最终费用以阿里云账单为准。</span>
</label>
</div>
<div class="top-action-row" style="justify-content: flex-end; margin-top: 24px;">
<button @click="ecsCreatePreview ? resetCreateEcsPreview() : closeCreateEcsModal()" class="ghost-button">
{{ ecsCreatePreview ? '返回修改' : '取消' }}
</button>
<button v-if="!ecsCreatePreview" @click="previewCreateEcs" class="solid-button" :disabled="ecsCreating || ecsPreviewing">
{{ ecsPreviewing ? '预检中...' : '下一步:查看清单' }}
</button>
<button v-else @click="confirmCreateEcs" class="solid-button" :disabled="ecsCreating || !ecsCreateConfirmed || !!ecsCreateResult">
{{ ecsCreating ? '创建中...' : '确认创建' }}
</button>
</div>
</div>
</div>
<div v-if="showCredentialModal && ecsCredentialResult" class="credential-modal-overlay">
<div class="glass-panel credential-modal">
<p class="subtle-label">重要登录信息</p>
<h2 class="credential-alert-title mt-2">请立即保存ECS 登录信息</h2>
<p class="mt-3 text-sm leading-7 text-[#64748b]">
这些信息只在本次创建完成后展示。为了安全,系统不会在任务记录、日志或通知里保存初始密码。
</p>
<div class="credential-grid">
<div class="credential-line">
<span class="text-sm text-[#64748b]">实例编号</span>
<div class="flex items-center gap-2">
<span class="credential-value">{{ ecsCredentialResult.instanceId }}</span>
<button @click="copyCredential(ecsCredentialResult.instanceId || '')" class="ghost-button compact-button">复制</button>
</div>
</div>
<div class="credential-line">
<span class="text-sm text-[#64748b]">公网地址</span>
<div class="flex items-center gap-2">
<span class="credential-value">{{ ecsCredentialResult.publicIp || '-' }}</span>
<button @click="copyCredential(ecsCredentialResult.publicIp || '')" class="ghost-button compact-button">复制</button>
</div>
</div>
<div class="credential-line">
<span class="text-sm text-[#64748b]">登录用户</span>
<div class="flex items-center gap-2">
<span class="credential-value">{{ ecsCredentialResult.loginUser }}</span>
<button @click="copyCredential(ecsCredentialResult.loginUser || '')" class="ghost-button compact-button">复制</button>
</div>
</div>
<div class="credential-line">
<span class="text-sm text-[#64748b]">初始密码</span>
<div class="flex items-center gap-2">
<span class="credential-value">{{ ecsCredentialResult.loginPassword }}</span>
<button @click="copyCredential(ecsCredentialResult.loginPassword || '')" class="solid-button compact-button">复制密码</button>
</div>
</div>
</div>
<label class="credential-save-check">
<input v-model="credentialSavedAck" type="checkbox" class="mt-1">
<span>我已将公网地址、登录用户和初始密码保存到安全位置。关闭后系统不会再次展示初始密码。</span>
</label>
<div class="dialog-actions">
<button @click="copyCredential(buildCredentialCopyText())" class="ghost-button">
复制全部
</button>
<button @click="closeCredentialModal" class="solid-button" :disabled="!credentialSavedAck">
我已保存,关闭
</button>
</div>
</div>
</div>
<div v-if="!initialized && !loadingCheckInit && !criticalError" class="setup-overlay">
<div class="glass-panel setup-card">
<div class="auth-brand">
<div class="auth-brand__logo">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="阿里云 Logo" class="brand-logo">
<div v-else class="brand-logo-fallback">C</div>
</div>
<div>
<p class="auth-brand__eyebrow">{{ appBrandName }}</p>
<h2 class="setup-title">初始化 {{ appBrandName }}</h2>
</div>
</div>
<p class="setup-desc">设置管理员密码、流量保护阈值和默认停机方式,完成后即可开始管理 ECS 实例。</p>
<div class="setup-form">
<div>
<label class="field-label">初始化 Token</label>
<input v-model="setupData.setup_token" type="password" class="glass-input" placeholder="由服务启动日志提供">
</div>
<div>
<label class="field-label">管理员密码</label>
<input v-model="setupData.admin_password" type="password" class="glass-input" placeholder="请输入管理员密码,至少 6 位">
</div>
<div class="setup-row">
<div>
<label class="field-label">流量保护阈值(%</label>
<input v-model.number="setupData.traffic_threshold" type="number" class="glass-input" placeholder="例如95">
</div>
<div>
<label class="field-label">默认停机方式</label>
<app-select
v-model="setupData.shutdown_mode"
class="glass-select"
placeholder="请选择停机方式"
:options="[
{ value: 'KeepCharging', label: '普通停机' },
{ value: 'StopCharging', label: '节省停机' }
]"
/>
</div>
</div>
</div>
<div class="setup-actions">
<button @click="performSetup" class="solid-button" :disabled="!setupData.admin_password">初始化系统</button>
</div>
</div>
</div>
</div>
<script src="static/vue.global.prod.js?v=__ASSET_VERSION__"></script>
<script>
// Keep legacy action URLs while attaching the Go backend's CSRF token.
const nativeFetch = window.fetch.bind(window);
let csrfToken = '';
window.fetch = (input, init = {}) => {
const requestUrl = typeof input === 'string' ? input : input.url;
const action = new URL(requestUrl, window.location.href).searchParams.get('action') || '';
const method = (init.method || (typeof input !== 'string' ? input.method : 'GET')).toUpperCase();
const headers = new Headers(init.headers || (typeof input !== 'string' ? input.headers : undefined));
if (csrfToken && (method !== 'GET' || action === 'logout')) headers.set('X-CSRF-Token', csrfToken);
if (action === 'setup') headers.delete('X-CSRF-Token');
return nativeFetch(input, { ...init, headers }).then((response) => {
const responseToken = response.headers.get('X-CSRF-Token');
if (responseToken) csrfToken = responseToken;
return response;
});
};
const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
const AppSelect = {
name: 'AppSelect',
props: {
modelValue: [String, Number],
options: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: '请选择'
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const open = ref(false);
const root = ref(null);
const instanceId = `select-${Math.random().toString(36).slice(2)}`;
const selectedOption = computed(() =>
props.options.find((option) => String(option.value) === String(props.modelValue)) || null
);
const isSelected = (option) => String(option.value) === String(props.modelValue);
const toggle = () => {
if (props.disabled) return;
if (!open.value) {
window.dispatchEvent(new CustomEvent('app-select-opened', {
detail: { id: instanceId }
}));
}
open.value = !open.value;
};
const selectOption = (option) => {
emit('update:modelValue', option.value);
open.value = false;
};
const handleClickOutside = (event) => {
if (root.value && !root.value.contains(event.target)) {
open.value = false;
}
};
const handleKeydown = (event) => {
if (!root.value || !root.value.contains(document.activeElement)) return;
if (event.key === 'Escape') {
open.value = false;
}
};
const handleSelectOpened = (event) => {
if (event?.detail?.id !== instanceId) {
open.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
window.addEventListener('app-select-opened', handleSelectOpened);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
window.removeEventListener('app-select-opened', handleSelectOpened);
});
return {
open,
root,
selectedOption,
isSelected,
toggle,
selectOption
};
},
template: `
<div ref="root" class="select-shell" :class="{ open, disabled }">
<button type="button" class="select-trigger" :disabled="disabled" @click.stop="toggle">
<span class="select-value" :class="{ 'is-placeholder': !selectedOption }">
{{ selectedOption ? selectedOption.label : placeholder }}
</span>
<span class="select-chevron">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m6 9 6 6 6-6"/>
</svg>
</span>
</button>
<transition name="select-pop">
<div v-if="open" class="select-menu">
<button
v-for="option in options"
:key="String(option.value)"
type="button"
class="select-option"
:class="{ selected: isSelected(option) }"
@click="selectOption(option)"
>
<span>{{ option.label }}</span>
<span class="select-check">
<svg v-if="isSelected(option)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="m5 13 4 4L19 7"/>
</svg>
</span>
</button>
</div>
</transition>
</div>
`
};
const TimePicker = {
name: 'TimePicker',
props: {
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请选择时间'
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const open = ref(false);
const root = ref(null);
const dropUp = ref(false);
const tempHour = ref('00');
const tempMinute = ref('00');
const instanceId = `time-${Math.random().toString(36).slice(2)}`;
const hours = Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, minute) => String(minute).padStart(2, '0'));
const syncTemp = () => {
const match = String(props.modelValue || '').match(/^(\d{2}):(\d{2})$/);
tempHour.value = match ? match[1] : '00';
tempMinute.value = match ? match[2] : '00';
};
const toggle = () => {
if (props.disabled) return;
if (!open.value) {
syncTemp();
window.dispatchEvent(new CustomEvent('app-select-opened', {
detail: { id: instanceId }
}));
requestAnimationFrame(() => {
if (!root.value) return;
const rect = root.value.getBoundingClientRect();
dropUp.value = (window.innerHeight - rect.bottom) < 320 && rect.top > 260;
});
}
open.value = !open.value;
};
const selectHour = (hour) => {
tempHour.value = hour;
};
const selectMinute = (minute) => {
tempMinute.value = minute;
};
const confirm = () => {
emit('update:modelValue', `${tempHour.value}:${tempMinute.value}`);
open.value = false;
};
const setNow = () => {
const now = new Date();
tempHour.value = String(now.getHours()).padStart(2, '0');
tempMinute.value = String(now.getMinutes()).padStart(2, '0');
confirm();
};
const handleClickOutside = (event) => {
if (root.value && !root.value.contains(event.target)) {
open.value = false;
}
};
const handleKeydown = (event) => {
if (!root.value || !root.value.contains(document.activeElement)) return;
if (event.key === 'Escape') {
open.value = false;
}
if (event.key === 'Enter' && open.value) {
confirm();
}
};
const handleSelectOpened = (event) => {
if (event?.detail?.id !== instanceId) {
open.value = false;
}
};
watch(() => props.modelValue, syncTemp, { immediate: true });
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
window.addEventListener('app-select-opened', handleSelectOpened);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
window.removeEventListener('app-select-opened', handleSelectOpened);
});
return {
open,
dropUp,
root,
hours,
minutes,
tempHour,
tempMinute,
toggle,
selectHour,
selectMinute,
confirm,
setNow
};
},
template: `
<div ref="root" class="time-picker" :class="{ open, 'drop-up': dropUp }">
<button type="button" class="time-picker__trigger" :disabled="disabled" @click.stop="toggle">
<span class="time-picker__value" :class="{ 'time-picker__placeholder': !modelValue }">
{{ modelValue || placeholder }}
</span>
<span class="time-picker__icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="9"/>
<path d="M12 7v5l3 2"/>
</svg>
</span>
</button>
<transition name="select-pop">
<div v-if="open" class="time-picker__panel" @click.stop>
<div class="time-picker__columns">
<div class="time-picker__column">
<button
v-for="hour in hours"
:key="'h-' + hour"
type="button"
class="time-picker__item"
:class="{ active: tempHour === hour }"
@click="selectHour(hour)"
>{{ hour }}</button>
</div>
<div class="time-picker__column">
<button
v-for="minute in minutes"
:key="'m-' + minute"
type="button"
class="time-picker__item"
:class="{ active: tempMinute === minute }"
@click="selectMinute(minute)"
>{{ minute }}</button>
</div>
</div>
<div class="time-picker__actions">
<button type="button" class="time-picker__action" @click="setNow">此刻</button>
<button type="button" class="time-picker__action time-picker__confirm" @click="confirm">确定</button>
</div>
</div>
</transition>
</div>
`
};
createApp({
components: {
AppSelect,
TimePicker
},
setup() {
const aliyunRegions = [
{ id: 'cn-hongkong', name: '中国香港' },
{ id: 'ap-southeast-1', name: '新加坡' },
{ id: 'ap-northeast-1', name: '日本(东京)' },
{ id: 'ap-northeast-2', name: '韩国(首尔)' },
{ id: 'us-west-1', name: '美国(硅谷)' },
{ id: 'us-east-1', name: '美国(弗吉尼亚)' },
{ id: 'eu-central-1', name: '德国(法兰克福)' },
{ id: 'eu-west-1', name: '英国(伦敦)' },
{ id: 'ap-southeast-2', name: '澳大利亚(悉尼)' },
{ id: 'ap-southeast-3', name: '马来西亚(吉隆坡)' },
{ id: 'ap-southeast-5', name: '印度尼西亚(雅加达)' },
{ id: 'ap-southeast-6', name: '菲律宾(马尼拉)' },
{ id: 'ap-southeast-7', name: '泰国(曼谷)' },
{ id: 'me-east-1', name: '阿联酋(迪拜)' },
{ id: 'cn-hangzhou', name: '华东 1杭州' },
{ id: 'cn-shanghai', name: '华东 2上海' },
{ id: 'cn-qingdao', name: '华北 1青岛' },
{ id: 'cn-beijing', name: '华北 2北京' },
{ id: 'cn-zhangjiakou', name: '华北 3张家口' },
{ id: 'cn-huhehaote', name: '华北 5呼和浩特' },
{ id: 'cn-wulanchabu', name: '华北 6乌兰察布' },
{ id: 'cn-shenzhen', name: '华南 1深圳' },
{ id: 'cn-heyuan', name: '华南 2河源' },
{ id: 'cn-guangzhou', name: '华南 3广州' },
{ id: 'cn-chengdu', name: '西南 1成都' }
];
const createDefaultAccount = () => ({
groupKey: '',
AccessKeyId: '',
AccessKeySecret: '',
regionId: '',
siteType: 'international',
maxTraffic: 200,
remark: '',
scheduleEnabled: false,
scheduleStartEnabled: false,
scheduleStopEnabled: false,
startTime: '',
stopTime: '',
scheduleBlockedByTraffic: false,
usageUsed: 0,
usageRemaining: 200,
usagePercent: 0,
instanceCount: 0,
usageLastUpdated: '',
trafficStatus: 'ok',
trafficMessage: ''
});
const createDefaultConfig = () => ({
admin_password: '',
password_login_enabled: true,
traffic_threshold: 95,
shutdown_mode: 'KeepCharging',
threshold_action: 'stop_and_notify',
keep_alive: false,
monthly_auto_start: false,
api_interval: 600,
enable_billing: false,
AppBrand: {
name: 'ECS 控制台',
logo_url: ''
},
Ddns: {
enabled: false,
provider: 'cloudflare',
domain: '',
cloudflare: {
zone_id: '',
token: '',
proxied: false
}
},
Notification: {
email_enabled: true,
email: '',
host: '',
port: 465,
username: '',
password: '',
secure: 'ssl',
daily_enabled: false,
daily_time: '00:00',
telegram: {
enabled: false,
token: '',
chat_id: '',
proxy_type: 'none',
proxy_url: '',
proxy_ip: '',
proxy_port: '',
proxy_user: '',
proxy_pass: '',
allowed_user_ids: '',
confirm_ttl: 60
},
webhook: {
enabled: false,
url: '',
method: 'GET',
request_type: 'JSON',
headers: '',
body: ''
}
},
Accounts: []
});
const ecsOsOptions = [
{ value: 'debian_12', label: 'Debian 12' },
{ value: 'alibaba_cloud_linux_3', label: 'Alibaba Cloud Linux 3' },
{ value: 'ubuntu_22', label: 'Ubuntu 22.04' },
{ value: 'ubuntu_24', label: 'Ubuntu 24.04' },
{ value: 'centos_stream_9', label: 'CentOS Stream 9' },
{ value: 'windows_2022', label: 'Windows Server 2022' }
];
const createDefaultEcsDraft = () => ({
accountGroupKey: '',
regionId: '',
instanceType: 'ecs.e-c4m1.large',
osKey: 'debian_12',
systemDiskSize: 20,
systemDiskCategory: 'cloud_essd_entry',
publicIpMode: 'ecs_public_ip',
instanceName: `launch-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '').slice(0, 12)}`
});
const config = ref(createDefaultConfig());
const updateInfo = ref({
configured: false,
current_version: 'dev',
current_commit: 'dev',
repository_url: '',
image_available: false,
source_update_available: false,
update_available: false,
latest: null,
message: ''
});
const updateState = ref({ status: 'idle', phase: '', progress: 0, message: '', target_commit: '', request_id: '' });
const updateReloadPending = ref(false);
const hasAvailableUpdate = computed(() => Boolean(
updateInfo.value.update_available &&
updateInfo.value.image_available &&
updateInfo.value.latest?.commit
));
const hasVersionUpdate = computed(() => Boolean(
updateInfo.value.source_update_available || updateInfo.value.update_available
));
const statusInstances = ref([]);
const allInstances = ref([]);
const systemLogs = ref([]);
const initialized = ref(true);
const loadingCheckInit = ref(true);
const loading = ref(true);
const initialWorkspaceLoading = ref(false);
const checkingLogin = ref(true);
const showLoginModal = ref(false);
const passwordInput = ref('');
const criticalError = ref(null);
const isAdmin = ref(false);
const cronWarning = ref(false);
let bootstrappingAdmin = false;
const navigationStorageKey = 'ecs-controller.navigation';
const readNavigationState = () => {
try {
const raw = window.sessionStorage.getItem(navigationStorageKey);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
return {};
}
};
const savedNavigation = readNavigationState();
const validMenus = ['status', 'manage', 'accounts', 'settings', 'logs', 'about'];
const validSettingsTabs = ['global', 'ddns', 'notifications'];
const validNotifyTabs = ['telegram', 'email', 'webhook'];
const validLogTabs = ['action', 'heartbeat'];
const currentMenu = ref(validMenus.includes(savedNavigation.menu) ? savedNavigation.menu : 'status');
const mobileNavHidden = ref(false);
const currentSettingsTab = ref(validSettingsTabs.includes(savedNavigation.settingsTab) ? savedNavigation.settingsTab : 'global');
const currentNotifyTab = ref(validNotifyTabs.includes(savedNavigation.notifyTab) ? savedNavigation.notifyTab : 'telegram');
const currentLogTab = ref(validLogTabs.includes(savedNavigation.logTab) ? savedNavigation.logTab : 'action');
const passkeySecureContext = typeof window !== 'undefined' && (
window.isSecureContext || ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname)
);
const passkeySupported = ref(passkeySecureContext && 'PublicKeyCredential' in window && !!navigator.credentials);
const passkeySupportHint = computed(() => {
if (!passkeySecureContext) return 'Passkey 需要 HTTPS 安全连接;本地测试可以使用 localhost。';
return '当前浏览器不支持 Passkey建议使用最新版 Chrome、Safari 或 Edge。';
});
const passkeyAvailable = ref(false);
const passkeyCount = ref(0);
const passwordLoginEnabled = ref(true);
const passkeyBusy = ref(false);
const persistNavigationState = () => {
try {
window.sessionStorage.setItem(navigationStorageKey, JSON.stringify({
menu: currentMenu.value,
settingsTab: currentSettingsTab.value,
notifyTab: currentNotifyTab.value,
logTab: currentLogTab.value
}));
} catch (error) {
// Storage can be unavailable in private browsing or embedded contexts.
}
};
let mobileNavLastScrollY = 0;
let mobileNavScrollFrame = 0;
const updateMobileNavVisibility = () => {
const currentScrollY = Math.max(window.scrollY || 0, 0);
const scrollDelta = currentScrollY - mobileNavLastScrollY;
if (currentScrollY <= 16) {
mobileNavHidden.value = false;
mobileNavLastScrollY = currentScrollY;
return;
}
if (Math.abs(scrollDelta) < 8) return;
mobileNavHidden.value = scrollDelta > 0 && currentScrollY > 72;
mobileNavLastScrollY = currentScrollY;
};
const handleMobileNavScroll = () => {
if (mobileNavScrollFrame) return;
mobileNavScrollFrame = window.requestAnimationFrame(() => {
mobileNavScrollFrame = 0;
updateMobileNavVisibility();
});
};
const setupMobileNavScroll = () => {
mobileNavLastScrollY = Math.max(window.scrollY || 0, 0);
window.addEventListener('scroll', handleMobileNavScroll, { passive: true });
};
const teardownMobileNavScroll = () => {
window.removeEventListener('scroll', handleMobileNavScroll);
if (mobileNavScrollFrame) {
window.cancelAnimationFrame(mobileNavScrollFrame);
mobileNavScrollFrame = 0;
}
};
const logAutoRefresh = ref(false);
const statusSyncInterval = ref(600);
const sendingEmail = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const uploadingLogo = ref(false);
const checkingUpdate = ref(false);
const startingUpdate = ref(false);
const updateScreenVisible = ref(false);
const updateScreenProgress = ref(0);
const updateScreenWaiting = ref(false);
const updateScreenCompleted = ref(false);
let updateInterval = null;
let updatePollTarget = '';
let updatePollRequestID = '';
let updatePollStartedAt = 0;
let updatePending = false;
let updateReloadTimer = null;
let updateSimulationTimer = null;
let updateScreenStartedAt = 0;
let updateReloadAttempted = false;
const updateSessionStorageKey = 'ecs-controller.online-update';
const updateScreenDuration = 30 * 1000;
const readUpdateSession = () => {
try {
const raw = window.sessionStorage.getItem(updateSessionStorageKey);
const value = raw ? JSON.parse(raw) : null;
return value && typeof value === 'object' ? value : null;
} catch (error) {
return null;
}
};
const saveUpdateSession = () => {
try {
window.sessionStorage.setItem(updateSessionStorageKey, JSON.stringify({
target: updatePollTarget,
requestId: updatePollRequestID,
startedAt: updatePollStartedAt,
reloadAttempted: updateReloadAttempted
}));
} catch (error) {
// Continue without resume support when session storage is unavailable.
}
};
const clearUpdateSession = () => {
try {
window.sessionStorage.removeItem(updateSessionStorageKey);
} catch (error) {
// Nothing else is required when storage is unavailable.
}
};
const fetchingAllInstances = ref(false);
const syncingInstances = ref(false);
const refreshingInstanceIds = ref([]);
const accountSyncStates = ref({});
const accountSyncResults = ref({});
const configBaseline = ref('');
const toasts = ref([]);
const dialogState = ref({
open: false,
mode: 'alert',
title: '提示',
message: '',
confirmLabel: '确定',
cancelLabel: '取消',
resolve: null
});
const showAccountModal = ref(false);
const savingAccountModal = ref(false);
const testingAccountDraft = ref(false);
const accountDraftTestResult = ref(null);
const accountModalMode = ref('create');
const editingAccountIndex = ref(-1);
const accountDraft = ref(createDefaultAccount());
const showBillingDetailsModal = ref(false);
const billingDetailsLoading = ref(false);
const billingDetailsError = ref('');
const selectedBillingDate = ref('');
const billingDetails = ref({
groupKey: '',
title: '',
days: 7,
from: '',
to: '',
currency: 'CNY',
total: 0,
items: [],
last_updated: ''
});
const billingHistoryDays = computed(() => {
const grouped = new Map();
(billingDetails.value.items || []).forEach((item) => {
const date = item.date || '未知日期';
if (!grouped.has(date)) {
grouped.set(date, {
date,
count: 0,
total: 0,
products: [],
items: []
});
}
const day = grouped.get(date);
const amount = Number(item.amount);
day.count += 1;
day.total += Number.isFinite(amount) ? amount : 0;
day.items.push(item);
const productName = item.product_name || item.product_code || '其他费用';
const product = item.billing_item
? `${productName} · ${item.billing_item}`
: productName;
if (!day.products.includes(product)) {
day.products.push(product);
}
});
return Array.from(grouped.values())
.map((day) => ({
...day,
productSummary: day.products.length > 3
? `${day.products.slice(0, 3).join('、')}${day.products.length}`
: day.products.join('、')
}))
.sort((left, right) => {
if (left.date === '未知日期') return 1;
if (right.date === '未知日期') return -1;
return right.date.localeCompare(left.date);
});
});
const selectedBillingDay = computed(() => {
if (!selectedBillingDate.value) return null;
return billingHistoryDays.value.find((day) => day.date === selectedBillingDate.value) || null;
});
const showCreateEcsModal = ref(false);
const ecsCreateDraft = ref(createDefaultEcsDraft());
const ecsCreatePreview = ref(null);
const ecsCreateConfirmed = ref(false);
const ecsCreateResult = ref(null);
const ecsCredentialResult = ref(null);
const showCredentialModal = ref(false);
const credentialSavedAck = ref(false);
const ecsPreviewing = ref(false);
const ecsCreating = ref(false);
const ecsDiskLoading = ref(false);
const ecsDiskCategoryOptions = ref([]);
const ecsDiskHint = ref('请选择账号并填写实例规格后获取硬盘类型。');
const setupData = ref({
setup_token: '',
admin_password: '',
traffic_threshold: 95,
shutdown_mode: 'KeepCharging'
});
const backupPassword = ref('');
const backupFile = ref(null);
const backupBusy = ref(false);
const statusFilter = ref({ account: '', search: '' });
const manageFilter = ref({ account: '', region: '', status: '', search: '' });
let logInterval = null;
let statusInterval = null;
let manageInterval = null;
let accountInterval = null;
const liveRefreshInterval = 5000;
const getRegionName = (regionId) => {
const found = aliyunRegions.find((item) => item.id === regionId);
return found ? found.name : (regionId || '-');
};
const getSiteTypeLabel = (siteType) => siteType === 'international' ? '国际站' : '中国站';
const formatTrafficUsage = (value) => Number(value || 0).toFixed(2).replace(/\.00$/, '');
const formatBillingNumber = (value) => {
const number = Number(value);
if (!Number.isFinite(number)) return '';
return number.toLocaleString('zh-CN', { maximumFractionDigits: 6 });
};
const formatBillingMetric = (item) => {
const value = formatBillingNumber(item?.usage);
if (!value) return '';
return [value, item?.unit].filter(Boolean).join(' ');
};
const billingItemKind = (item) => {
const text = [item?.product_name, item?.product_code, item?.product_detail, item?.billing_item, item?.billing_item_code]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (text.includes('imageos')) return 'imageos';
if (text.includes('系统盘') || text.includes('systemdisk')) return 'system-disk';
if (text.includes('公网ip保有') || text.includes('eip保有') || (text.includes('弹性公网ip') && text.includes('保有'))) return 'eip-holding';
if (text.includes('出方向流量') || text.includes('outbound traffic')) return 'traffic';
if (text.includes('云服务器配置') || text.includes('instance configuration')) return 'instance-configuration';
return 'generic';
};
const formatCurrentResourceStatus = (value) => {
const status = String(value || '').trim();
return status.toLowerCase() === 'in_use' ? 'InUse' : status;
};
const billingConfigPairs = (value) => String(value || '')
.split(/[;]/)
.map((part) => {
const separator = part.includes('') ? '' : ':';
const index = part.indexOf(separator);
return index > 0 ? {
key: part.slice(0, index).trim(),
value: part.slice(index + separator.length).trim()
} : null;
})
.filter((part) => part?.key && part?.value && part.value !== '-');
const billingConfigValue = (pairs, ...keys) => {
const pair = pairs.find((candidate) => keys.includes(candidate.key))
|| pairs.find((candidate) => keys.some((key) => candidate.key.includes(key)));
return pair?.value || '';
};
const billingConfigSummary = (item, kind) => {
const pairs = billingConfigPairs(item?.instance_config);
if (pairs.length === 0 || kind === 'traffic') return '';
if (kind === 'system-disk') {
const size = billingConfigValue(pairs, '系统盘大小');
const category = billingConfigValue(pairs, '系统盘种类');
const parts = [size, category].filter(Boolean);
return parts.length > 0 ? `账单配置:${parts.join(' · ')}` : '';
}
if (kind === 'instance-configuration' || kind === 'imageos') {
const spec = billingConfigValue(pairs, '实例规格');
const cpu = billingConfigValue(pairs, 'cpu核数');
const memory = billingConfigValue(pairs, '内存');
const os = billingConfigValue(pairs, '操作系统的类型', '操作系统详情');
const compute = spec || [cpu, memory].filter(Boolean).join(' ');
const parts = [compute, os].filter(Boolean);
return parts.length > 0 ? `账单配置:${parts.join(' · ')}` : '';
}
if (kind === 'eip-holding') {
const peakBandwidth = billingConfigValue(pairs, '带宽峰值');
const lineType = billingConfigValue(pairs, '线路类型');
const numericBandwidth = Number(peakBandwidth.replace(/[^\d.]/g, ''));
const bandwidth = /kbps/i.test(peakBandwidth) && numericBandwidth >= 1024
? `${formatBillingNumber(numericBandwidth / 1024)} Mbps`
: peakBandwidth;
const parts = [bandwidth, lineType].filter(Boolean);
return parts.length > 0 ? `账单配置:${parts.join(' · ')}` : '';
}
return '';
};
const billingUsagePresentation = (item) => {
const usage = formatBillingMetric(item);
const resource = item?.current_resource || {};
const disk = resource.system_disk || null;
const eip = resource.eip || null;
const kind = billingItemKind(item);
const config = billingConfigSummary(item, kind);
if (kind === 'traffic') {
return {
primary: usage ? `实际出方向流量:${usage}` : '实际出方向流量未返回',
current: '',
billing: '',
config
};
}
if (kind === 'system-disk') {
const current = disk?.category || disk?.status
? `当前配置:${[disk?.category, formatCurrentResourceStatus(disk?.status)].filter(Boolean).join(' · ')}`
: '';
return {
primary: Number(disk?.size_gib) > 0 ? `系统盘 ${disk.size_gib} GiB` : '系统盘当前配置未返回',
current,
billing: usage ? `账单累计容量时长:${usage}·小时` : '账单累计容量时长未返回',
config
};
}
if (kind === 'eip-holding') {
const servicePeriod = Number(item?.service_period_seconds);
const hours = Number.isFinite(servicePeriod) && servicePeriod > 0
? formatBillingNumber(servicePeriod / 3600)
: formatBillingNumber(item?.usage);
const current = eip
? `当前配置:${[formatCurrentResourceStatus(eip.status), Number(eip.bandwidth_mbps) > 0 ? `${eip.bandwidth_mbps} Mbps` : ''].filter(Boolean).join(' · ')}`
: '';
return {
primary: eip ? `${eip.count || 1} 个 EIP` : '公网 IP 保有服务',
current,
billing: hours ? `账单保有时长:${hours} 小时` : '账单保有时长未返回',
config
};
}
if (kind === 'instance-configuration' || kind === 'imageos') {
const hours = formatBillingNumber(item?.usage);
return {
primary: hours ? `账单计费时长:${hours} 小时` : '账单计费时长未返回',
current: '',
billing: '此值用于账单计费,不代表实例实际运行时长。',
config
};
}
return {
primary: usage ? `账单累计用量:${usage}` : '账单用量未返回',
current: '',
billing: usage ? '账单累计用量不等同于当前资源数量或容量。' : '',
config
};
};
const formatTrafficValue = (value) => {
const traffic = Number(value || 0);
if (!Number.isFinite(traffic) || traffic <= 0) return '0 MB';
if (traffic >= 1) return `${traffic.toFixed(2).replace(/\.00$/, '')} GB`;
const mb = traffic * 1024;
return `${mb.toFixed(2).replace(/\.00$/, '')} MB`;
};
const formatPercent = (value) => {
const percent = Number(value || 0);
if (!Number.isFinite(percent)) return '0';
return Math.round(percent).toString();
};
const formatInstanceSpec = (item) => {
const cpu = Number(item?.cpu);
const memory = Number(item?.memory);
if (!Number.isFinite(cpu) || cpu <= 0 || !Number.isFinite(memory) || memory <= 0) {
return item?.instanceType || '-';
}
const memoryLabel = memory < 1024 ? `${memory} MiB` : `${(memory / 1024).toFixed(1)} GiB`;
return `${cpu}${memoryLabel}`;
};
const formatPublicBandwidth = (item) => {
const bandwidth = Number(item?.internetMaxBandwidthOut);
return Number.isFinite(bandwidth) && bandwidth > 0 ? `${bandwidth} Mbps` : '未设置';
};
const hasTrafficIssue = (item) => {
const status = item?.trafficStatus || 'ok';
return status !== '' && status !== 'ok';
};
const trafficIssueText = (item) => {
const status = item?.trafficStatus || 'ok';
const message = (item?.trafficMessage || '').trim();
if (message) return message;
if (status === 'permission_denied') return '缺少云监控权限';
if (status === 'auth_error') return '云监控鉴权失败';
if (status === 'timeout') return '云监控请求超时';
if (status === 'partial') return '部分实例流量同步失败';
return '流量同步失败';
};
const formatCurrencyValue = (value, currency = 'CNY') => {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
return '--';
}
const symbol = currency === 'USD' ? '$' : currency === 'CNY' ? '¥' : `${currency} `;
return `${symbol}${amount.toFixed(2).replace(/\.00$/, '')}`;
};
const formatBillingAmount = (value, currency = 'CNY', fixedDecimals = null) => {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount)) {
return '--';
}
const symbol = currency === 'USD' ? '$' : currency === 'CNY' ? '¥' : `${currency} `;
const precision = Number.isInteger(fixedDecimals)
? Math.max(0, fixedDecimals)
: amount !== 0 && Math.abs(amount) < 0.01 ? 6 : 2;
let formatted = amount.toFixed(precision);
if (!Number.isInteger(fixedDecimals) && precision > 2) {
formatted = formatted.replace(/0+$/, '').replace(/\.$/, '');
} else if (!Number.isInteger(fixedDecimals)) {
formatted = formatted.replace(/\.00$/, '');
}
return `${symbol}${formatted || '0'}`;
};
const formatBillingDate = (value) => {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
return match ? `${match[1]}${match[2]}${match[3]}` : (value || '未知日期');
};
const diskUnitText = (unit) => {
if (!unit || unit === 'GiB' || unit === 'GB') return 'GB';
return unit;
};
const diskCategoryText = (category) => ({
cloud_essd_entry: 'ESSD Entry 云盘',
cloud_essd: '增强型固态盘',
cloud_auto: 'ESSD AutoPL 云盘',
cloud_ssd: 'SSD 云盘',
cloud_efficiency: '高效云盘',
cloud: '普通云盘'
}[category] || category || '-');
const defaultBrandLogo = 'static/aliyun-logo.png';
const defaultBrandName = 'ECS 控制台';
const appBrandName = computed(() => (config.value.AppBrand?.name || defaultBrandName).trim() || defaultBrandName);
const brandLogoUrl = computed(() => (config.value.AppBrand?.logo_url || defaultBrandLogo).trim());
const updateFavicon = (url) => {
const icon = document.getElementById('app-favicon');
if (!icon) return;
const nextUrl = url || defaultBrandLogo;
const cleanUrl = nextUrl.split('?')[0].toLowerCase();
const iconType = cleanUrl.endsWith('.webp') ? 'image/webp'
: (cleanUrl.endsWith('.jpg') || cleanUrl.endsWith('.jpeg') ? 'image/jpeg' : 'image/png');
icon.setAttribute('href', nextUrl);
icon.setAttribute('type', iconType);
};
const updateDocumentTitle = (name) => {
document.title = name || defaultBrandName;
};
const ecsDiskCategorySelectOptions = computed(() => {
if (ecsDiskLoading.value) {
return [{ value: ecsCreateDraft.value.systemDiskCategory || '', label: '正在获取硬盘类型...' }];
}
if (ecsDiskCategoryOptions.value.length === 0) {
return [{ value: '', label: '暂无可用硬盘类型' }];
}
return ecsDiskCategoryOptions.value.map((item) => ({
value: item.value,
label: item.label || diskCategoryText(item.value)
})).filter((item) => item.value);
});
const getAccountKey = (account, index) => account?.groupKey || `${account?.remark || 'account'}-${account?.regionId || 'region'}-${index}`;
const isRefreshingInstance = (id) => refreshingInstanceIds.value.includes(id);
let toastCounter = 0;
const pushToast = (message, type = 'info') => {
if (!message) return;
const id = `${Date.now()}-${toastCounter++}`;
toasts.value = [...toasts.value, { id, message, type }];
setTimeout(() => {
toasts.value = toasts.value.filter((toast) => toast.id !== id);
}, 3200);
};
const openAlert = (message, type = 'info') => {
pushToast(message, type);
};
const copyCredential = async (value) => {
if (!value) {
openAlert('没有可复制的内容', 'error');
return;
}
const copyText = String(value).replace(/\\n/g, '\n');
try {
await navigator.clipboard.writeText(copyText);
openAlert('已复制到剪贴板', 'success');
} catch (error) {
const textarea = document.createElement('textarea');
textarea.value = copyText;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
openAlert('已复制到剪贴板', 'success');
}
};
const buildCredentialCopyText = () => {
const result = ecsCredentialResult.value || {};
return [
`地址: ${result.publicIp || '-'}`,
`用户: ${result.loginUser || '-'}`,
`密码: ${result.loginPassword || '-'}`
].join('\n');
};
const askConfirm = (message, options = {}) => new Promise((resolve) => {
dialogState.value = {
open: true,
mode: 'confirm',
title: options.title || '请确认',
message,
confirmLabel: options.confirmLabel || '确认',
cancelLabel: options.cancelLabel || '取消',
resolve
};
});
const resolveDialog = (confirmed) => {
const resolver = dialogState.value.resolve;
dialogState.value = {
open: false,
mode: 'alert',
title: '提示',
message: '',
confirmLabel: '确定',
cancelLabel: '取消',
resolve: null
};
if (typeof resolver === 'function') {
resolver(confirmed);
}
};
const getAccountSyncResult = (index) => {
const account = config.value.Accounts[index];
if (!account) return null;
return accountSyncResults.value[getAccountKey(account, index)] || null;
};
const isSyncingAccount = (index) => {
const account = config.value.Accounts[index];
if (!account) return false;
return !!accountSyncStates.value[getAccountKey(account, index)];
};
const getAccountSyncLabel = (index) => {
if (isSyncingAccount(index)) return '同步中...';
const result = getAccountSyncResult(index);
if (!result) return '待同步';
return result.success ? '已同步' : '同步错误';
};
const getAccountSyncButtonClass = (index) => {
const result = getAccountSyncResult(index);
if (!result) return 'pending';
return result.success ? 'synced' : 'error';
};
const maskAccessKey = (value) => {
if (!value) return '未配置账号';
return value.length <= 7 ? value : `${value.slice(0, 7)}***`;
};
const accountOptions = computed(() => {
const options = [];
const seen = new Set();
const groups = config.value?.Accounts || [];
groups.forEach((group) => {
const key = group.groupKey || `${group.AccessKeyId}|${group.regionId}`;
if (!group.AccessKeyId || !group.regionId || seen.has(key)) return;
seen.add(key);
const labelBase = group.remark || maskAccessKey(group.AccessKeyId);
options.push({
value: key,
label: `${labelBase} / ${getRegionName(group.regionId)}`
});
});
if (options.length > 0) return options;
statusInstances.value.forEach((item) => {
const key = item.groupKey || item.accountMasked;
if (!key || seen.has(key)) return;
seen.add(key);
options.push({
value: key,
label: item.accountLabel || item.accountMasked
});
});
return options;
});
const selectedCreateAccount = computed(() => {
return (config.value.Accounts || []).find((account) => {
const key = account.groupKey || `${account.AccessKeyId}|${account.regionId}`;
return key === ecsCreateDraft.value.accountGroupKey;
}) || null;
});
const filteredStatusInstances = computed(() => {
const search = statusFilter.value.search.trim().toLowerCase();
return statusInstances.value.filter((item) => {
if (statusFilter.value.account && item.groupKey !== statusFilter.value.account) {
return false;
}
if (!search) return true;
return [
item.remark,
item.instanceName,
item.instanceId,
item.accountLabel
].some((field) => (field || '').toLowerCase().includes(search));
});
});
const filteredManagedInstances = computed(() => {
const search = manageFilter.value.search.trim().toLowerCase();
return allInstances.value.filter((item) => {
if (manageFilter.value.account && item.groupKey !== manageFilter.value.account) return false;
if (manageFilter.value.region && item.regionId !== manageFilter.value.region) return false;
if (manageFilter.value.status && item.instanceStatus !== manageFilter.value.status) return false;
if (!search) return true;
return [
item.remark,
item.instanceName,
item.instanceId,
item.accountLabel
].some((field) => (field || '').toLowerCase().includes(search));
});
});
const settingsItems = [
{ key: 'global', label: '全局设置', description: '运行参数、阈值与费用' },
{ key: 'ddns', label: 'DDNS', description: '域名与公网 IP 同步' },
{ key: 'notifications', label: '通知中心', description: '邮件、Telegram、回调' }
];
const menuItems = computed(() => {
const items = [
{
key: 'status',
label: '实例状态',
description: '实例、流量与使用情况',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19.5V9m8 10.5V4.5m8 15V13"/></svg>'
}
];
if (isAdmin.value) {
items.push(
{
key: 'manage',
label: '实例管理',
description: '创建、启动与关机',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7h16M4 12h16M4 17h10"/></svg>'
},
{
key: 'accounts',
label: '账号管理',
description: '阿里云账号与流量',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>'
},
{
key: 'settings',
label: '系统设置',
description: '运行参数与通知',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.08V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-.4-1.08 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.08-.4H2.9a2 2 0 0 1 0-4H3a1.7 1.7 0 0 0 1.08-.4 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.08V2.9a2 2 0 0 1 4 0V3a1.7 1.7 0 0 0 .4 1.08 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 .6 1 1.7 1.7 0 0 0 1.08.4h.09a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.08.4 1.7 1.7 0 0 0-.6 1Z"/></svg>'
},
{
key: 'logs',
label: '系统日志',
description: '操作与心跳记录',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>'
},
{
key: 'about',
label: '关于',
description: '版本、项目与打赏',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 10v6"/><path d="M12 7.2h.01"/></svg>'
}
);
}
return items;
});
const normalizeConfig = (payload) => {
const next = createDefaultConfig();
Object.assign(next, payload || {});
if (next.admin_password === '********') {
next.admin_password = '';
}
next.password_login_enabled = next.password_login_enabled !== false;
next.Notification = {
...next.Notification,
...(payload?.Notification || {})
};
next.Notification.telegram = payload?.Notification?.telegram || next.Notification.telegram;
next.Notification.webhook = payload?.Notification?.webhook || next.Notification.webhook;
next.AppBrand = {
...next.AppBrand,
...(payload?.AppBrand || {})
};
next.Ddns = {
...next.Ddns,
...(payload?.Ddns || {}),
cloudflare: {
...next.Ddns.cloudflare,
...(payload?.Ddns?.cloudflare || {})
}
};
next.Accounts = Array.isArray(payload?.Accounts) ? payload.Accounts.map((item) => ({
...createDefaultAccount(),
...item
})) : [];
return next;
};
const buildSettingsSnapshot = (value) => {
const source = value || {};
return JSON.stringify({
admin_password: source.admin_password || '',
password_login_enabled: source.password_login_enabled !== false,
traffic_threshold: Number(source.traffic_threshold || 95),
shutdown_mode: source.shutdown_mode || 'KeepCharging',
threshold_action: source.threshold_action || 'stop_and_notify',
keep_alive: !!source.keep_alive,
monthly_auto_start: !!source.monthly_auto_start,
api_interval: Number(source.api_interval || 600),
enable_billing: !!source.enable_billing,
AppBrand: source.AppBrand || {},
Ddns: source.Ddns || {},
Notification: source.Notification || {}
});
};
const markConfigClean = () => {
configBaseline.value = buildSettingsSnapshot(config.value);
};
const settingsDirty = computed(() => {
return isAdmin.value && currentMenu.value === 'settings' && configBaseline.value !== '' && buildSettingsSnapshot(config.value) !== configBaseline.value;
});
const fetchStatusData = async (restartPolling = true) => {
if (!isAdmin.value) {
statusInstances.value = [];
loading.value = false;
cronWarning.value = false;
stopStatusPolling();
return;
}
loading.value = true;
try {
const res = await fetch('index.php?action=get_status');
const json = await res.json();
if (!res.ok || json.error) {
if (res.status === 403 || json.error === 'Unauthorized' || json.error === '请先登录后再操作') {
isAdmin.value = false;
statusInstances.value = [];
stopStatusPolling();
return;
}
criticalError.value = json.error;
statusInstances.value = [];
} else {
statusInstances.value = json.data || [];
statusSyncInterval.value = Number(json.sync_interval || statusSyncInterval.value || 600);
const now = Math.floor(Date.now() / 1000);
cronWarning.value = json.system_last_run ? (now - json.system_last_run) > 180 : statusInstances.value.length > 0;
if (restartPolling) {
startStatusPolling();
}
}
} catch (error) {
console.error('Fetch status failed', error);
statusInstances.value = [];
} finally {
loading.value = false;
}
};
const fetchConfig = async () => {
try {
const res = await fetch('index.php?action=get_config');
const data = await res.json();
config.value = normalizeConfig(data);
passwordLoginEnabled.value = data.password_login_enabled !== false;
passkeyCount.value = Number(data.passkey_count || 0);
passkeyAvailable.value = passkeyCount.value > 0;
markConfigClean();
} catch (error) {
console.error('Fetch config failed', error);
}
};
const stopUpdatePolling = () => {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
};
const stopUpdateSimulation = () => {
if (updateSimulationTimer) {
clearInterval(updateSimulationTimer);
updateSimulationTimer = null;
}
};
const hideUpdateScreen = () => {
stopUpdateSimulation();
if (updateReloadTimer) {
clearTimeout(updateReloadTimer);
updateReloadTimer = null;
}
updateScreenVisible.value = false;
updateScreenProgress.value = 0;
updateScreenWaiting.value = false;
updateScreenCompleted.value = false;
updateScreenStartedAt = 0;
};
const scheduleUpdateReload = () => {
updateReloadPending.value = true;
updateScreenVisible.value = true;
updateScreenWaiting.value = false;
updateScreenCompleted.value = true;
updateScreenProgress.value = 100;
if (updateReloadTimer) return;
updateReloadTimer = window.setTimeout(() => {
updateReloadTimer = null;
updateReloadAttempted = true;
saveUpdateSession();
persistNavigationState();
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set('_updated', String(Date.now()));
window.location.replace(nextUrl.toString());
}, 450);
};
const advanceUpdateSimulation = () => {
if (!updateScreenStartedAt) return;
const elapsed = Math.max(0, Date.now() - updateScreenStartedAt);
if (updateReloadAttempted) {
// A reload may happen before the updater finishes. Keep the
// screen calm while the backend completes the real update.
updateScreenVisible.value = true;
updateScreenProgress.value = Math.min(96, Math.max(8, Math.round(8 + elapsed / updateScreenDuration * 88)));
return;
}
if (elapsed >= updateScreenDuration) {
stopUpdateSimulation();
scheduleUpdateReload();
return;
}
const ratio = Math.min(1, elapsed / updateScreenDuration);
// Ease out so the fake progress advances quickly at first and
// slows down naturally while the real update completes.
const easedRatio = 1 - Math.pow(1 - ratio, 2.4);
updateScreenProgress.value = Math.min(94, Math.max(4, Math.round(4 + easedRatio * 90)));
};
const startUpdateSimulation = (startedAt = Date.now()) => {
stopUpdateSimulation();
updateScreenStartedAt = startedAt;
updateScreenVisible.value = true;
updateScreenWaiting.value = updateReloadAttempted;
updateScreenCompleted.value = false;
advanceUpdateSimulation();
if (updateReloadAttempted || updateScreenProgress.value >= 100) return;
updateSimulationTimer = window.setInterval(advanceUpdateSimulation, 250);
};
const ensureUpdatePolling = () => {
if (!updateInterval) {
updateInterval = window.setInterval(fetchUpdateStatus, 2000);
}
};
const resumeOnlineUpdateTracking = async () => {
const saved = readUpdateSession();
const startedAt = Number(saved?.startedAt || 0);
if (!saved?.target || !startedAt || Date.now() - startedAt > 20 * 60 * 1000) {
if (saved) clearUpdateSession();
return;
}
updatePending = true;
updatePollTarget = String(saved.target || '').toLowerCase();
updatePollRequestID = String(saved.requestId || '');
updatePollStartedAt = startedAt;
updateReloadAttempted = Boolean(saved.reloadAttempted);
updateReloadPending.value = true;
updateState.value = {
...updateState.value,
status: 'queued',
phase: 'queued',
progress: 0,
message: '正在恢复更新状态,请稍候。',
target_commit: updatePollTarget,
request_id: updatePollRequestID
};
startUpdateSimulation(startedAt);
ensureUpdatePolling();
await fetchUpdateStatus();
};
const updateStatusMatchesPending = (data) => {
if (!updatePending) return true;
// Older updater images appended a literal newline to JSON
// identifiers; trim them so upgrades remain trackable.
const target = String(data?.target_commit || '').trim().toLowerCase();
const requestID = String(data?.request_id || '').trim();
if (updatePollRequestID && requestID && requestID !== updatePollRequestID) {
return false;
}
if (updatePollTarget && target && target !== updatePollTarget) {
return false;
}
if (!requestID && !target) {
return false;
}
const updatedAt = Number(data?.updated_at || 0) * 1000;
if (updatedAt > 0 && updatePollStartedAt > 0 && updatedAt < updatePollStartedAt - 1000) {
return false;
}
return true;
};
const updateTargetAlreadyLoaded = (data) => {
if (!updateReloadAttempted || !updatePollTarget) return false;
const currentCommit = String(data?.current_commit || '').trim().toLowerCase();
return Boolean(currentCommit && currentCommit === updatePollTarget);
};
const finishReloadedUpdate = () => {
updatePending = false;
updatePollTarget = '';
updatePollRequestID = '';
stopUpdatePolling();
clearUpdateSession();
updateReloadAttempted = false;
updateReloadPending.value = false;
hideUpdateScreen();
openAlert('更新完成,已加载最新版本', 'success');
void checkForUpdates({ silent: true });
};
const fetchUpdateStatus = async () => {
const pendingAtRequest = updatePending;
try {
const res = await fetch('index.php?action=get_update_status', { cache: 'no-store' });
if (!res.ok) throw new Error(`update status HTTP ${res.status}`);
const data = await res.json();
if (pendingAtRequest && !updateStatusMatchesPending(data)) {
if (updateTargetAlreadyLoaded(data)) {
finishReloadedUpdate();
return;
}
ensureUpdatePolling();
return;
}
updateState.value = { status: 'idle', phase: '', progress: 0, message: '', ...data };
if (['queued', 'running'].includes(updateState.value.status)) {
ensureUpdatePolling();
} else if (pendingAtRequest && updateState.value.status === 'success') {
updateReloadPending.value = true;
if (updateReloadAttempted) {
finishReloadedUpdate();
} else {
if (!updateScreenVisible.value) {
startUpdateSimulation(updatePollStartedAt || Date.now());
}
}
} else if (pendingAtRequest && updateState.value.status === 'error') {
updatePending = false;
updatePollTarget = '';
updatePollRequestID = '';
stopUpdatePolling();
hideUpdateScreen();
updateReloadPending.value = false;
updateReloadAttempted = false;
clearUpdateSession();
openAlert(updateState.value.message || '在线更新失败,请查看系统日志', 'error');
} else {
stopUpdatePolling();
}
} catch (error) {
console.error('Fetch update status failed', error);
if (pendingAtRequest) {
if (Date.now() - updatePollStartedAt > 15 * 60 * 1000) {
updatePending = false;
updatePollTarget = '';
updatePollRequestID = '';
stopUpdatePolling();
hideUpdateScreen();
updateReloadPending.value = false;
updateReloadAttempted = false;
clearUpdateSession();
updateState.value = { ...updateState.value, status: 'error', phase: 'failed', message: '更新状态长时间未响应,请检查 updater 容器和系统日志。' };
openAlert(updateState.value.message, 'error');
} else {
updateState.value = { ...updateState.value, phase: 'restarting', message: '正在等待更新服务响应,页面会继续跟踪进度。' };
ensureUpdatePolling();
}
}
}
};
const confirmAndStartUpdate = async (info = updateInfo.value) => {
const targetCommit = info.latest?.commit;
if (!targetCommit) {
openAlert('请先检查更新', 'error');
return false;
}
const currentVersion = info.current_version || '未知';
const latestVersion = info.latest?.version || '未知';
const confirmed = await askConfirm(`当前版本:${currentVersion};最新版本:${latestVersion}。更新将拉取预构建 Docker 镜像,不会在本机编译镜像。`, {
title: '确认立即更新?',
confirmLabel: '立即更新'
});
if (!confirmed) return false;
await startOnlineUpdate(targetCommit);
return true;
};
const checkForUpdates = async ({ silent = false } = {}) => {
checkingUpdate.value = true;
try {
const res = await fetch('index.php?action=check_update');
const data = await res.json();
updateInfo.value = { ...updateInfo.value, ...data, message: data.message || '' };
await fetchUpdateStatus();
if (!data.success) {
if (!silent) openAlert(data.message || '检查更新失败', 'error');
} else if (data.configured === false) {
if (!silent) openAlert(data.message || '当前部署未启用 Docker 在线更新', 'info');
} else if (data.source_update_available && !data.image_available) {
if (!silent) openAlert(data.message || '对应的预构建 Docker 镜像尚未发布,请稍后重试', 'info');
} else if (!data.update_available) {
if (!silent) openAlert('当前已是最新版本', 'success');
} else {
if (!silent) await confirmAndStartUpdate(data);
}
} catch (error) {
console.error('Check update failed', error);
if (!silent) openAlert('检查更新请求失败', 'error');
} finally {
checkingUpdate.value = false;
}
};
const handleUpdateAction = async () => {
if (hasAvailableUpdate.value) {
await confirmAndStartUpdate();
return;
}
await checkForUpdates();
};
const startOnlineUpdate = async (targetCommit = updateInfo.value.latest?.commit) => {
if (!targetCommit) {
openAlert('请先检查更新', 'error');
return;
}
startingUpdate.value = true;
try {
const res = await fetch('index.php?action=start_update', {
method: 'POST',
body: JSON.stringify({ target_commit: targetCommit })
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || '在线更新提交失败', 'error');
return;
}
updatePending = true;
updatePollTarget = targetCommit.toLowerCase();
updatePollRequestID = String(data.request_id || '');
updatePollStartedAt = Date.now();
updateReloadAttempted = false;
saveUpdateSession();
updateReloadPending.value = true;
updateState.value = {
...updateState.value,
status: 'queued',
phase: 'queued',
progress: 0,
message: '正在更新,请稍候。',
target_commit: targetCommit,
request_id: updatePollRequestID
};
startUpdateSimulation(updatePollStartedAt);
stopUpdatePolling();
ensureUpdatePolling();
await fetchUpdateStatus();
} catch (error) {
console.error('Start online update failed', error);
updatePending = false;
updatePollTarget = '';
updatePollRequestID = '';
updateReloadAttempted = false;
clearUpdateSession();
stopUpdatePolling();
hideUpdateScreen();
updateReloadPending.value = false;
openAlert('在线更新请求失败', 'error');
} finally {
startingUpdate.value = false;
}
};
const fetchAllInstances = async (sync = false, options = {}) => {
if (fetchingAllInstances.value && options.silent) {
return;
}
fetchingAllInstances.value = true;
try {
const endpoint = sync ? 'index.php?action=sync_instances' : 'index.php?action=get_all_instances';
const res = await fetch(endpoint, sync ? {
method: 'POST',
body: JSON.stringify({})
} : undefined);
const data = await res.json();
if (data.error) {
if (!options.silent) {
openAlert(data.error, 'error');
}
allInstances.value = [];
} else {
allInstances.value = data.data || [];
if (sync && !options.silent) {
openAlert('实例已从阿里云同步完成', 'success');
}
}
} catch (error) {
console.error('Fetch instances failed', error);
if (!options.silent) {
openAlert('实例列表请求失败', 'error');
}
} finally {
fetchingAllInstances.value = false;
}
};
const reloadInstanceViews = async ({ sync = false, silent = true } = {}) => {
const tasks = [];
if (isAdmin.value) {
tasks.push(fetchStatusData(false));
if (currentMenu.value === 'manage') {
tasks.push(fetchAllInstances(sync, { silent }));
}
}
await Promise.all(tasks);
};
const syncCloudInstances = async () => {
syncingInstances.value = true;
try {
const res = await fetch('index.php?action=sync_instances', {
method: 'POST',
body: JSON.stringify({})
});
const data = await res.json();
if (data.error) {
openAlert(data.error, 'error');
} else {
allInstances.value = data.data || [];
statusInstances.value = data.data || [];
openAlert('实例状态已与阿里云同步完成', 'success');
fetchStatusData(false);
}
} catch (error) {
console.error('Sync instances failed', error);
openAlert('同步请求失败', 'error');
} finally {
syncingInstances.value = false;
}
};
const fetchLogs = async () => {
if (!isAdmin.value) return;
try {
const res = await fetch(`index.php?action=get_logs&tab=${currentLogTab.value}`);
const data = await res.json();
systemLogs.value = data.data || [];
} catch (error) {
console.error('Fetch logs failed', error);
}
};
const clearLogs = async () => {
const confirmed = await askConfirm(`确定清空${currentLogTab.value === 'action' ? '动作' : '心跳'}日志吗?`, {
title: '清空日志'
});
if (!confirmed) return;
try {
const res = await fetch('index.php?action=clear_logs', {
method: 'POST',
body: JSON.stringify({ tab: currentLogTab.value })
});
const data = await res.json();
if (data.success) {
fetchLogs();
}
} catch (error) {
console.error('Clear logs failed', error);
}
};
const startLogPolling = () => {
fetchLogs();
logAutoRefresh.value = true;
if (logInterval) clearInterval(logInterval);
logInterval = setInterval(fetchLogs, 3000);
};
const stopLogPolling = () => {
logAutoRefresh.value = false;
if (logInterval) {
clearInterval(logInterval);
logInterval = null;
}
};
const stopStatusPolling = () => {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
};
const stopManagePolling = () => {
if (manageInterval) {
clearInterval(manageInterval);
manageInterval = null;
}
};
const stopAccountPolling = () => {
if (accountInterval) {
clearInterval(accountInterval);
accountInterval = null;
}
};
const startStatusPolling = () => {
stopStatusPolling();
if (!initialized.value || criticalError.value || currentMenu.value !== 'status') {
return;
}
statusInterval = setInterval(() => {
fetchStatusData(false);
}, liveRefreshInterval);
};
const startManagePolling = () => {
stopManagePolling();
if (!initialized.value || criticalError.value || currentMenu.value !== 'manage' || !isAdmin.value) {
return;
}
manageInterval = setInterval(() => {
fetchAllInstances(false, { silent: true });
}, liveRefreshInterval);
};
const startAccountPolling = () => {
stopAccountPolling();
if (!initialized.value || criticalError.value || currentMenu.value !== 'accounts' || !isAdmin.value) {
return;
}
accountInterval = setInterval(() => {
// Do not replace an account draft while the edit modal is open.
if (!showAccountModal.value) {
fetchConfig();
}
}, liveRefreshInterval);
};
const validateAccounts = () => {
const accounts = config.value.Accounts || [];
const seenAccountRegions = new Set();
for (const account of accounts) {
const isEmpty = !account.AccessKeyId && !account.AccessKeySecret && !account.regionId && !account.remark;
if (isEmpty) continue;
if (!account.remark || !account.AccessKeyId || !account.AccessKeySecret || !account.regionId || !account.maxTraffic) {
openAlert('账号管理中存在未填完整的行请补齐备注、AK、区域和账号流量。', 'error');
return false;
}
const key = accountRegionKey(account);
if (seenAccountRegions.has(key)) {
openAlert('账号管理中存在重复的账号和区域组合,请更换区域。', 'error');
return false;
}
seenAccountRegions.add(key);
}
return true;
};
const accountRegionKey = (account) => {
const accessKeyID = String(account?.AccessKeyId || '').trim();
const regionID = String(account?.regionId || '').trim().toLowerCase();
return accessKeyID && regionID ? `${accessKeyID}|${regionID}` : '';
};
const persistConfig = async (successMessage = '配置已保存并完成实例同步') => {
if (!validateAccounts()) return;
try {
const res = await fetch('index.php?action=save_config', {
method: 'POST',
body: JSON.stringify(config.value)
});
const data = await res.json();
if (data.success) {
await fetchConfig();
markConfigClean();
await fetchStatusData();
if (isAdmin.value) {
await fetchAllInstances(false);
}
if (successMessage) {
openAlert(successMessage, 'success');
}
return true;
} else {
openAlert(data.message || '保存失败', 'error');
return false;
}
} catch (error) {
console.error('Save config failed', error);
openAlert('保存请求失败', 'error');
return false;
}
};
const saveConfig = async () => {
return persistConfig();
};
const createBackup = async () => {
if (backupBusy.value) return;
if (backupPassword.value.length < 8) {
openAlert('备份密码至少需要 8 个字符', 'error');
return;
}
backupBusy.value = true;
try {
const res = await fetch('index.php?action=create_backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: backupPassword.value })
});
const contentType = res.headers.get('Content-Type') || '';
if (!res.ok || contentType.includes('application/json')) {
const data = await res.json().catch(() => ({}));
openAlert(data.message || '备份创建失败', 'error');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ecs-controller-backup-${new Date().toISOString().slice(0, 10)}.ecsbkp`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
openAlert('加密备份已下载', 'success');
} catch (error) {
console.error('Create backup failed', error);
openAlert('备份请求失败', 'error');
} finally {
backupBusy.value = false;
}
};
const restoreBackup = async () => {
if (backupBusy.value || !backupFile.value) return;
if (backupPassword.value.length < 8) {
openAlert('请输入备份密码,至少 8 个字符', 'error');
return;
}
const confirmed = await askConfirm(
'恢复会覆盖当前账号、系统设置、流量历史和 Passkey并清除当前登录会话。恢复成功后需要使用备份中的管理员凭据重新登录。确定继续吗',
{ title: '确认恢复备份', confirmLabel: '确认恢复' }
);
if (!confirmed) return;
const formData = new FormData();
formData.append('password', backupPassword.value);
formData.append('backup', backupFile.value);
backupBusy.value = true;
try {
const res = await fetch('index.php?action=restore_backup', {
method: 'POST',
body: formData
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) {
openAlert(data.message || '备份恢复失败', 'error');
return;
}
openAlert(data.message || '备份已恢复,正在重新加载', 'success');
window.setTimeout(() => window.location.reload(), 800);
} catch (error) {
console.error('Restore backup failed', error);
openAlert('恢复请求失败', 'error');
} finally {
backupBusy.value = false;
}
};
const uploadLogo = async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const formData = new FormData();
formData.append('logo', file);
uploadingLogo.value = true;
try {
const res = await fetch('index.php?action=upload_logo', {
method: 'POST',
body: formData
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || 'Logo 上传失败', 'error');
return;
}
config.value.AppBrand.logo_url = data.url || '';
markConfigClean();
openAlert('Logo 已更新', 'success');
} catch (error) {
console.error('Upload logo failed', error);
openAlert('Logo 上传请求失败', 'error');
} finally {
uploadingLogo.value = false;
}
};
const clearLogo = () => {
config.value.AppBrand.logo_url = '';
};
const refreshInstanceCard = async (id) => {
refreshingInstanceIds.value = [...new Set([...refreshingInstanceIds.value, id])];
try {
const res = await fetch('index.php?action=refresh_account', {
method: 'POST',
body: JSON.stringify({ id })
});
const data = await res.json();
if (!data.success && !data.billing_error) {
if (res.status === 404 || res.status === 409) {
await reloadInstanceViews({ silent: true });
openAlert(data.message || '云端实例不存在,已从面板移除', 'info');
return;
}
openAlert(data.message || '刷新失败', 'error');
return;
}
await fetchStatusData(false);
if (isAdmin.value && currentMenu.value === 'manage') {
await fetchAllInstances(false);
}
const refreshed = statusInstances.value.find((item) => item.id === id);
if (data.billing_error) {
openAlert(`实例已刷新,账单查询异常:${data.billing_error}`, 'info');
} else if (refreshed && hasTrafficIssue(refreshed)) {
openAlert(`实例已刷新,${trafficIssueText(refreshed)}`, 'info');
} else if (refreshed) {
openAlert(`实例已刷新,当前流量 ${formatTrafficValue(refreshed.flow_used)}`, 'success');
} else {
openAlert('实例已刷新', 'success');
}
} catch (error) {
console.error('Refresh instance failed', error);
openAlert('刷新请求失败', 'error');
} finally {
refreshingInstanceIds.value = refreshingInstanceIds.value.filter((itemId) => itemId !== id);
}
};
const patchInstanceLists = (accountId, changes = {}, options = {}) => {
const id = Number(accountId);
const patchItem = (item) => {
if (Number(item.id) !== id && Number(item.accountId) !== id) {
return item;
}
return {
...item,
...changes,
status: changes.instanceStatus || changes.status || item.status,
instanceStatus: changes.instanceStatus || changes.status || item.instanceStatus,
lastUpdated: new Date().toLocaleString('zh-CN', { hour12: false })
};
};
if (options.remove) {
allInstances.value = allInstances.value.filter((item) => Number(item.id) !== id && Number(item.accountId) !== id);
statusInstances.value = statusInstances.value.filter((item) => Number(item.id) !== id && Number(item.accountId) !== id);
return;
}
allInstances.value = allInstances.value.map(patchItem);
statusInstances.value = statusInstances.value.map(patchItem);
};
const canReplaceIp = (inst) => {
return isAdmin.value && inst && !inst.operationLocked && inst.instanceStatus !== 'Releasing' && inst.publicIpMode === 'eip' && !!inst.eipManaged;
};
const controlInstance = async (accountId, action) => {
const label = action === 'start' ? '启动' : '停止';
const confirmed = await askConfirm(`确定${label}该实例吗?`, {
title: `${label}实例`
});
if (!confirmed) return;
const nextStatus = action === 'start' ? 'Starting' : 'Stopping';
patchInstanceLists(accountId, { instanceStatus: nextStatus, status: nextStatus });
refreshingInstanceIds.value = [...new Set([...refreshingInstanceIds.value, accountId])];
try {
const res = await fetch('index.php?action=control_instance', {
method: 'POST',
body: JSON.stringify({ accountId, action, shutdownMode: config.value.shutdown_mode || 'KeepCharging' })
});
const data = await res.json();
if (!data.success) {
openAlert('操作失败', 'error');
return;
}
openAlert(`${label}指令已提交,实例状态正在更新`, 'success');
await reloadInstanceViews({ silent: true });
window.setTimeout(() => reloadInstanceViews({ silent: true }), 3500);
} catch (error) {
console.error('Control instance failed', error);
openAlert('操作请求失败', 'error');
await reloadInstanceViews({ silent: true });
} finally {
refreshingInstanceIds.value = refreshingInstanceIds.value.filter((itemId) => Number(itemId) !== Number(accountId));
}
};
const replaceInstanceIp = async (accountId) => {
const confirmed = await askConfirm('确定更换这台实例的公网 IP 吗?旧 EIP 会被释放且不可恢复DDNS 会自动同步到新 IP。', {
title: '更换公网 IP',
confirmLabel: '确认更换'
});
if (!confirmed) return;
refreshingInstanceIds.value = [...new Set([...refreshingInstanceIds.value, accountId])];
try {
const res = await fetch('index.php?action=replace_instance_ip', {
method: 'POST',
body: JSON.stringify({ accountId })
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || '公网 IP 更换失败', 'error');
return;
}
const next = data.data || {};
patchInstanceLists(accountId, {
publicIp: next.publicIp || '',
publicIpMode: 'eip',
eipAllocationId: next.eipAllocationId || '',
eipAddress: next.eipAddress || next.publicIp || '',
eipManaged: true,
internetMaxBandwidthOut: next.internetMaxBandwidthOut || 0
});
openAlert(`公网 IP 已更换为 ${next.publicIp || '-'}`, 'success');
await reloadInstanceViews({ silent: true });
} catch (error) {
console.error('Replace instance IP failed', error);
openAlert('公网 IP 更换请求失败', 'error');
} finally {
refreshingInstanceIds.value = refreshingInstanceIds.value.filter((itemId) => Number(itemId) !== Number(accountId));
}
};
const deleteInstance = async (accountId) => {
const confirmed = await askConfirm('确定释放这台实例吗?该操作不可恢复。', {
title: '释放实例',
confirmLabel: '确认释放'
});
if (!confirmed) return;
patchInstanceLists(accountId, { instanceStatus: 'Releasing', status: 'Releasing' });
refreshingInstanceIds.value = [...new Set([...refreshingInstanceIds.value, accountId])];
try {
const res = await fetch('index.php?action=delete_instance', {
method: 'POST',
body: JSON.stringify({ accountId, forceStop: true })
});
const data = await res.json();
if (!data.success) {
openAlert('释放失败', 'error');
await reloadInstanceViews({ silent: true });
return;
}
patchInstanceLists(accountId, {
instanceStatus: 'Releasing',
status: 'Releasing',
operationLocked: true,
operationLockedReason: '实例正在释放中,后台队列会继续处理。'
});
openAlert('释放指令已提交,实例进入释放队列', 'success');
await reloadInstanceViews({ silent: true });
window.setTimeout(() => reloadInstanceViews({ silent: true }), 3500);
} catch (error) {
console.error('Delete instance failed', error);
openAlert('释放请求失败', 'error');
await reloadInstanceViews({ silent: true });
} finally {
refreshingInstanceIds.value = refreshingInstanceIds.value.filter((itemId) => Number(itemId) !== Number(accountId));
}
};
const syncAccount = async (index) => {
const account = config.value.Accounts[index];
if (!account) return;
if (!account.groupKey) {
openAlert('账号信息不完整,请先保存账号后再同步。', 'error');
return;
}
const key = getAccountKey(account, index);
accountSyncStates.value = { ...accountSyncStates.value, [key]: true };
try {
const res = await fetch('index.php?action=sync_account_group', {
method: 'POST',
body: JSON.stringify({ groupKey: account.groupKey })
});
const data = await res.json();
if (!res.ok || !data.success) {
const message = data.message || '账号同步失败';
accountSyncResults.value = {
...accountSyncResults.value,
[key]: { success: false, message }
};
openAlert(message, 'error');
return;
}
await fetchConfig();
await fetchAllInstances(false);
await fetchStatusData(false);
accountSyncResults.value = {
...accountSyncResults.value,
[key]: {
success: true,
message: data.message || '同步完成'
}
};
openAlert(data.message || '账号已同步', 'success');
} catch (error) {
console.error('Sync account failed', error);
accountSyncResults.value = {
...accountSyncResults.value,
[key]: { success: false, message: '同步请求失败,请稍后重试' }
};
openAlert('同步请求失败,请稍后重试', 'error');
} finally {
accountSyncStates.value = { ...accountSyncStates.value, [key]: false };
}
};
const openCreateAccountModal = () => {
accountModalMode.value = 'create';
editingAccountIndex.value = -1;
accountDraft.value = createDefaultAccount();
accountDraftTestResult.value = null;
showAccountModal.value = true;
};
const openEditAccountModal = (index) => {
accountModalMode.value = 'edit';
editingAccountIndex.value = index;
accountDraft.value = {
...createDefaultAccount(),
...(config.value.Accounts[index] || {})
};
accountDraftTestResult.value = null;
showAccountModal.value = true;
};
const closeAccountModal = () => {
showAccountModal.value = false;
savingAccountModal.value = false;
testingAccountDraft.value = false;
accountDraftTestResult.value = null;
editingAccountIndex.value = -1;
accountDraft.value = createDefaultAccount();
};
const loadBillingDetails = async () => {
const groupKey = billingDetails.value.groupKey;
if (!groupKey) return;
billingDetailsLoading.value = true;
billingDetailsError.value = '';
try {
const res = await fetch('index.php?action=get_bill_details', {
method: 'POST',
body: JSON.stringify({ group_key: groupKey, days: billingDetails.value.days })
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || '账单明细查询失败');
}
const payload = data.data || {};
billingDetails.value = {
...billingDetails.value,
...payload,
items: Array.isArray(payload.items) ? payload.items : []
};
} catch (error) {
console.error('Load billing details failed', error);
billingDetailsError.value = error.message || '账单明细查询失败,请稍后重试';
} finally {
billingDetailsLoading.value = false;
}
};
const openBillingDetails = async (account) => {
if (!account?.groupKey) {
openAlert('当前账号暂时无法查询账单明细', 'error');
return;
}
selectedBillingDate.value = '';
billingDetails.value = {
groupKey: account.groupKey,
title: account.remark || '未命名账号',
days: 7,
from: '',
to: '',
currency: account.billing?.currency || 'CNY',
total: 0,
items: [],
last_updated: ''
};
billingDetailsError.value = '';
showBillingDetailsModal.value = true;
await loadBillingDetails();
};
const openBillingDay = (date) => {
if (billingHistoryDays.value.some((day) => day.date === date)) {
selectedBillingDate.value = date;
}
};
const backToBillingHistory = () => {
selectedBillingDate.value = '';
};
const reloadBillingDetails = () => loadBillingDetails();
const closeBillingDetails = () => {
showBillingDetailsModal.value = false;
selectedBillingDate.value = '';
billingDetailsLoading.value = false;
billingDetailsError.value = '';
};
const validateAccountDraft = () => {
const draft = accountDraft.value;
if (!draft.remark || !draft.AccessKeyId || !draft.AccessKeySecret || !draft.regionId || !draft.maxTraffic) {
openAlert('请填写完整的账号信息包括备注、AK、区域和账号流量。', 'error');
return false;
}
const draftKey = accountRegionKey(draft);
const duplicateIndex = (config.value.Accounts || []).findIndex((account, index) => {
return index !== editingAccountIndex.value && accountRegionKey(account) === draftKey;
});
if (duplicateIndex >= 0) {
openAlert('该账号已经添加了这个区域,请选择其他区域。', 'error');
return false;
}
if (draft.scheduleEnabled) {
if (!draft.scheduleStartEnabled && !draft.scheduleStopEnabled) {
openAlert('已开启定时开关机,请至少启用一个定时动作。', 'error');
return false;
}
if (draft.scheduleStartEnabled && !draft.startTime) {
openAlert('请填写定时开机时间。', 'error');
return false;
}
if (draft.scheduleStopEnabled && !draft.stopTime) {
openAlert('请填写定时停机时间。', 'error');
return false;
}
}
return true;
};
const saveAccountModal = async () => {
if (!validateAccountDraft()) return;
savingAccountModal.value = true;
const nextAccounts = [...(config.value.Accounts || [])];
const draft = {
...createDefaultAccount(),
...accountDraft.value
};
if (accountModalMode.value === 'edit' && editingAccountIndex.value > -1) {
nextAccounts.splice(editingAccountIndex.value, 1, draft);
} else {
nextAccounts.push(draft);
}
const previousAccounts = config.value.Accounts;
config.value.Accounts = nextAccounts;
const success = await persistConfig('账号已保存并完成实例同步');
if (success) {
closeAccountModal();
return;
}
config.value.Accounts = previousAccounts;
savingAccountModal.value = false;
};
const testAccountDraft = async () => {
if (!validateAccountDraft()) return;
testingAccountDraft.value = true;
accountDraftTestResult.value = null;
try {
const res = await fetch('index.php?action=test_account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: accountDraft.value })
});
const data = await res.json();
if (!res.ok || !data.success) {
accountDraftTestResult.value = {
success: false,
message: data.message || '账号测试失败',
monitorStatus: 'warning',
monitorMessage: data.message || '账号测试失败'
};
openAlert(data.message || '账号测试失败', 'error');
return;
}
accountDraftTestResult.value = data;
openAlert(data.monitorStatus === 'warning' ? '账号测试完成,云监控权限待补充' : '账号测试通过', data.monitorStatus === 'warning' ? 'info' : 'success');
} catch (error) {
console.error('Test account failed', error);
accountDraftTestResult.value = {
success: false,
message: '账号测试请求失败,请稍后重试',
monitorStatus: 'warning',
monitorMessage: '账号测试请求失败,请稍后重试'
};
openAlert('账号测试请求失败,请稍后重试', 'error');
} finally {
testingAccountDraft.value = false;
}
};
const openCreateEcsModal = () => {
const draft = createDefaultEcsDraft();
const firstAccount = config.value.Accounts?.[0];
if (firstAccount) {
draft.accountGroupKey = firstAccount.groupKey || `${firstAccount.AccessKeyId}|${firstAccount.regionId}`;
draft.regionId = firstAccount.regionId || '';
}
ecsCreateDraft.value = draft;
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
showCreateEcsModal.value = true;
scheduleLoadEcsDiskOptions();
};
const closeCreateEcsModal = () => {
if (ecsCreating.value) return;
showCreateEcsModal.value = false;
ecsCreateDraft.value = createDefaultEcsDraft();
resetDiskOptions();
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
};
const closeCredentialModal = () => {
if (!credentialSavedAck.value) {
openAlert('请先确认已保存登录信息', 'error');
return;
}
showCredentialModal.value = false;
ecsCredentialResult.value = null;
credentialSavedAck.value = false;
};
let diskOptionTimer = null;
const resetDiskOptions = (hint = '请选择账号并填写实例规格后获取硬盘类型。') => {
ecsDiskCategoryOptions.value = [];
ecsDiskHint.value = hint;
};
const loadEcsDiskOptions = async () => {
const account = selectedCreateAccount.value;
if (!showCreateEcsModal.value || ecsCreatePreview.value) return;
if (!account || !ecsCreateDraft.value.instanceType) {
resetDiskOptions();
return;
}
ecsCreateDraft.value.regionId = account.regionId || '';
ecsDiskLoading.value = true;
try {
const res = await fetch('index.php?action=get_ecs_disk_options', {
method: 'POST',
body: JSON.stringify({
accountGroupKey: ecsCreateDraft.value.accountGroupKey,
regionId: ecsCreateDraft.value.regionId,
instanceType: ecsCreateDraft.value.instanceType
})
});
const json = await res.json();
if (!res.ok || !json.success) {
resetDiskOptions(json.message || '硬盘类型获取失败,请检查实例规格。');
return;
}
const options = json.data?.options || [];
ecsDiskCategoryOptions.value = options;
if (options.length === 0) {
resetDiskOptions('当前规格没有返回可用硬盘类型,请更换实例规格。');
return;
}
if (!options.some((item) => item.value === ecsCreateDraft.value.systemDiskCategory)) {
ecsCreateDraft.value.systemDiskCategory = options[0].value;
}
ecsDiskHint.value = `来自阿里云 API${getRegionName(json.data.regionId)} / ${json.data.zoneId}`;
} catch (error) {
console.error('Fetch ECS disk options failed', error);
resetDiskOptions('硬盘类型请求失败,请稍后重试。');
} finally {
ecsDiskLoading.value = false;
}
};
const scheduleLoadEcsDiskOptions = () => {
if (diskOptionTimer) {
clearTimeout(diskOptionTimer);
}
diskOptionTimer = setTimeout(loadEcsDiskOptions, 450);
};
const resetCreateEcsPreview = () => {
if (ecsCreating.value) return;
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
};
const previewCreateEcs = async () => {
const account = selectedCreateAccount.value;
if (!account) {
openAlert('请先选择用于创建 ECS 的账号', 'error');
return;
}
if (!ecsCreateDraft.value.instanceType) {
openAlert('请填写实例规格,例如 ecs.e-c4m1.large', 'error');
return;
}
ecsCreateDraft.value.regionId = account.regionId || '';
const diskSize = Number(ecsCreateDraft.value.systemDiskSize || 0);
if (!Number.isFinite(diskSize) || diskSize < 1) {
openAlert('系统盘大小请填写大于 0 的整数,具体范围会在下一步由阿里云 API 校验。', 'error');
return;
}
if (ecsDiskCategoryOptions.value.length === 0) {
await loadEcsDiskOptions();
}
if (!ecsCreateDraft.value.systemDiskCategory || ecsDiskCategoryOptions.value.length === 0) {
openAlert('当前账号区域和实例规格没有可用硬盘类型,请更换实例规格后重试。', 'error');
return;
}
ecsPreviewing.value = true;
try {
const res = await fetch('index.php?action=preview_ecs_create', {
method: 'POST',
body: JSON.stringify(ecsCreateDraft.value)
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || '创建预检失败', 'error');
return;
}
ecsCreatePreview.value = data;
ecsCreateConfirmed.value = false;
} catch (error) {
console.error('Preview ECS failed', error);
openAlert('创建预检请求失败', 'error');
} finally {
ecsPreviewing.value = false;
}
};
const confirmCreateEcs = async () => {
if (!ecsCreatePreview.value || !ecsCreateConfirmed.value) return;
ecsCreating.value = true;
try {
const res = await fetch('index.php?action=create_ecs', {
method: 'POST',
body: JSON.stringify({
previewId: ecsCreatePreview.value.previewId,
confirmed: true
})
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || 'ECS 创建失败', 'error');
return;
}
let result = data.data || {};
if (data.queued && data.taskId) {
for (let attempt = 0; attempt < 120; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const taskRes = await fetch(`index.php?action=get_ecs_create_task&taskId=${encodeURIComponent(data.taskId)}`);
const taskJson = await taskRes.json();
const task = taskJson.data || {};
if (task.status === 'success') { result = task; break; }
if (task.status === 'failed') throw new Error(task.error_message || '后台创建失败');
if (attempt === 119) throw new Error('后台创建超时,请在任务状态中查看结果');
}
}
ecsCreateResult.value = result;
ecsCredentialResult.value = result;
credentialSavedAck.value = false;
showCreateEcsModal.value = false;
showCredentialModal.value = true;
openAlert('ECS 创建成功,登录信息已弹出,请立即保存', 'success');
await fetchConfig();
await fetchAllInstances(true);
await fetchStatusData(false);
} catch (error) {
console.error('Create ECS failed', error);
openAlert('ECS 创建请求失败', 'error');
} finally {
ecsCreating.value = false;
}
};
const removeAccountRow = async (index) => {
const confirmed = await askConfirm('确定删除这条账号配置吗?', {
title: '删除账号配置',
confirmLabel: '确认删除'
});
if (!confirmed) return;
const previousAccounts = [...config.value.Accounts];
config.value.Accounts.splice(index, 1);
const success = await persistConfig('账号已删除并完成实例同步');
if (!success) {
config.value.Accounts = previousAccounts;
}
};
const sendTestEmail = async () => {
if (!config.value.Notification.email) {
openAlert('请先填写接收邮箱', 'error');
return;
}
sendingEmail.value = true;
try {
const res = await fetch('index.php?action=send_test_email', {
method: 'POST',
body: JSON.stringify({ email: config.value.Notification.email })
});
const data = await res.json();
openAlert(data.success ? '测试邮件已发送' : `发送失败:${data.message}`, data.success ? 'success' : 'error');
} catch (error) {
console.error(error);
openAlert('发送请求失败', 'error');
} finally {
sendingEmail.value = false;
}
};
const sendTestTelegram = async () => {
testingTelegram.value = true;
try {
const res = await fetch('index.php?action=send_test_telegram', {
method: 'POST',
body: JSON.stringify({ telegram: config.value.Notification.telegram })
});
const data = await res.json();
openAlert(data.success ? 'Telegram 测试消息已发送' : `发送失败:${data.message}`, data.success ? 'success' : 'error');
} catch (error) {
console.error(error);
openAlert('发送请求失败', 'error');
} finally {
testingTelegram.value = false;
}
};
const sendTestWebhook = async () => {
testingWebhook.value = true;
try {
const res = await fetch('index.php?action=send_test_webhook', {
method: 'POST',
body: JSON.stringify({ webhook: config.value.Notification.webhook })
});
const data = await res.json();
openAlert(data.success ? '接口回调 测试消息已发送' : `发送失败:${data.message}`, data.success ? 'success' : 'error');
} catch (error) {
console.error(error);
openAlert('发送请求失败', 'error');
} finally {
testingWebhook.value = false;
}
};
const passkeyBase64urlToBuffer = (value) => {
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
};
const passkeyBufferToBase64url = (value) => {
const bytes = value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
let binary = '';
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
};
const serializePasskeyCredential = (credential) => {
if (!credential || !credential.response) {
throw new Error('未收到 Passkey 凭据');
}
const response = credential.response;
const result = {
id: credential.id,
rawId: passkeyBufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: passkeyBufferToBase64url(response.clientDataJSON)
}
};
if (response.attestationObject) {
result.response.attestationObject = passkeyBufferToBase64url(response.attestationObject);
}
if (response.authenticatorData) {
result.response.authenticatorData = passkeyBufferToBase64url(response.authenticatorData);
}
if (response.signature) {
result.response.signature = passkeyBufferToBase64url(response.signature);
}
result.response.userHandle = response.userHandle
? passkeyBufferToBase64url(response.userHandle)
: null;
return result;
};
const passkeyCreationOptions = (data) => {
const publicKey = { ...data.publicKey };
publicKey.challenge = passkeyBase64urlToBuffer(publicKey.challenge);
publicKey.user = {
...publicKey.user,
id: passkeyBase64urlToBuffer(publicKey.user.id)
};
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((item) => ({
...item,
id: passkeyBase64urlToBuffer(item.id)
}));
return publicKey;
};
const passkeyAssertionOptions = (data) => {
const publicKey = { ...data.publicKey };
publicKey.challenge = passkeyBase64urlToBuffer(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map((item) => ({
...item,
id: passkeyBase64urlToBuffer(item.id)
}));
return publicKey;
};
const passkeyErrorMessage = (error, fallback) => {
if (error?.name === 'NotAllowedError') return 'Passkey 操作已取消';
return error?.message || fallback;
};
const registerPasskey = async () => {
if (!passkeySupported.value || typeof navigator.credentials?.create !== 'function') {
openAlert('当前浏览器不支持 Passkey', 'error');
return;
}
passkeyBusy.value = true;
try {
const startRes = await fetch('index.php?action=passkey_register_start', {
method: 'POST',
body: JSON.stringify({})
});
const startData = await startRes.json();
if (!startRes.ok || !startData.publicKey) {
throw new Error(startData.message || 'Passkey 注册初始化失败');
}
const credential = await navigator.credentials.create({
publicKey: passkeyCreationOptions(startData)
});
const finishRes = await fetch('index.php?action=passkey_register_finish', {
method: 'POST',
body: JSON.stringify(serializePasskeyCredential(credential))
});
const finishData = await finishRes.json();
if (!finishRes.ok || !finishData.success) {
throw new Error(finishData.message || 'Passkey 注册失败');
}
passkeyCount.value = Number(finishData.count || passkeyCount.value + 1);
passkeyAvailable.value = passkeyCount.value > 0;
openAlert('Passkey 已注册,以后可以免输入密码登录', 'success');
} catch (error) {
console.error('Register passkey failed', error);
openAlert(passkeyErrorMessage(error, 'Passkey 注册失败'), 'error');
} finally {
passkeyBusy.value = false;
}
};
const loginWithPasskey = async () => {
if (!passkeySupported.value || typeof navigator.credentials?.get !== 'function') {
openAlert('当前浏览器不支持 Passkey', 'error');
return;
}
passkeyBusy.value = true;
try {
const startRes = await fetch('index.php?action=passkey_login_start', {
method: 'POST',
body: JSON.stringify({})
});
const startData = await startRes.json();
if (!startRes.ok || !startData.publicKey) {
throw new Error(startData.message || 'Passkey 登录初始化失败');
}
const getOptions = { publicKey: passkeyAssertionOptions(startData) };
if (startData.mediation) getOptions.mediation = startData.mediation;
const credential = await navigator.credentials.get(getOptions);
const finishRes = await fetch('index.php?action=passkey_login_finish', {
method: 'POST',
body: JSON.stringify(serializePasskeyCredential(credential))
});
const finishData = await finishRes.json();
if (!finishRes.ok || !finishData.success) {
throw new Error(finishData.message || 'Passkey 登录失败');
}
passwordInput.value = '';
showLoginModal.value = false;
isAdmin.value = true;
await fetchConfig();
await fetchAllInstances(false);
} catch (error) {
console.error('Passkey login failed', error);
openAlert(passkeyErrorMessage(error, 'Passkey 登录失败'), 'error');
} finally {
passkeyBusy.value = false;
}
};
const performLogin = async () => {
if (!passwordLoginEnabled.value) {
openAlert('密码登录已关闭,请使用 Passkey 登录', 'error');
return;
}
try {
const res = await fetch('index.php?action=login', {
method: 'POST',
body: JSON.stringify({ password: passwordInput.value })
});
const data = await res.json();
if (!data.success) {
openAlert(data.message || '登录失败', 'error');
return;
}
passwordInput.value = '';
showLoginModal.value = false;
isAdmin.value = true;
await fetchConfig();
await fetchAllInstances(false);
} catch (error) {
console.error(error);
openAlert('登录请求失败', 'error');
}
};
const toggleAdmin = async () => {
if (!isAdmin.value) {
showLoginModal.value = true;
return;
}
await fetch('index.php?action=logout');
isAdmin.value = false;
currentMenu.value = 'status';
config.value = createDefaultConfig();
configBaseline.value = '';
allInstances.value = [];
stopLogPolling();
stopManagePolling();
};
const performSetup = async () => {
try {
const res = await fetch('index.php?action=setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Setup-Token': setupData.value.setup_token },
body: JSON.stringify({
...createDefaultConfig(),
...setupData.value
})
});
const data = await res.json();
if (data.success) {
reloadPage();
} else {
openAlert(data.message || '初始化失败', 'error');
}
} catch (error) {
console.error(error);
openAlert('初始化请求失败', 'error');
}
};
const checkLoginStatus = async () => {
checkingLogin.value = true;
try {
const res = await fetch('index.php?action=check_login');
const data = await res.json();
csrfToken = data.logged_in ? (data.csrf_token || '') : '';
isAdmin.value = !!data.logged_in;
} catch (error) {
console.error('Session check failed', error);
} finally {
checkingLogin.value = false;
}
};
const checkInitStatus = async () => {
loadingCheckInit.value = true;
try {
const res = await fetch('index.php?action=check_init');
const data = await res.json();
if (data.error) {
criticalError.value = data.error;
return;
}
passkeyCount.value = Number(data.passkey_count || 0);
passkeyAvailable.value = !!data.passkey_enabled;
passwordLoginEnabled.value = data.password_login_enabled !== false;
config.value.AppBrand.name = data.brand?.name || defaultBrandName;
config.value.AppBrand.logo_url = data.brand?.logo_url || '';
initialized.value = !!data.initialized;
if (initialized.value) {
bootstrappingAdmin = true;
await checkLoginStatus();
if (isAdmin.value) {
initialWorkspaceLoading.value = true;
void (async () => {
try {
// These local reads do not depend on each other, so avoid adding a full network round trip to first paint.
await Promise.all([fetchConfig(), resumeOnlineUpdateTracking()]);
await syncCurrentMenu();
} catch (error) {
console.error('Initial workspace load failed', error);
} finally {
initialWorkspaceLoading.value = false;
bootstrappingAdmin = false;
}
})();
} else {
bootstrappingAdmin = false;
loading.value = false;
}
} else {
bootstrappingAdmin = false;
loading.value = false;
}
} catch (error) {
console.error('Init check failed', error);
criticalError.value = '无法连接服务器或响应格式错误。';
} finally {
loadingCheckInit.value = false;
}
};
const statusClass = (status) => {
if (status === 'Running') return 'status-running';
if (status === 'Stopped' || status === 'Released') return 'status-stopped';
if (status === 'Starting' || status === 'Stopping' || status === 'Pending' || status === 'Releasing') return 'status-transition';
if (status === 'ReleaseFailed') return 'status-error';
return 'status-unknown';
};
const statusText = (status) => ({
Running: '运行中',
Stopped: '已停止',
Released: '已释放',
Releasing: '释放中',
ReleaseFailed: '释放失败,可重试',
Starting: '启动中',
Stopping: '停止中',
Pending: '创建中',
Unknown: '未知'
}[status] || '未知');
const logTypeLabel = (type) => ({
info: '信息',
warning: '提醒',
error: '错误',
heartbeat: '检测'
}[type] || '信息');
const reloadPage = () => {
persistNavigationState();
window.location.reload();
};
const syncCurrentMenu = async (menu = currentMenu.value) => {
if (menu === 'status' && isAdmin.value) {
await fetchStatusData();
} else {
stopStatusPolling();
}
if (menu === 'manage' && isAdmin.value) {
await fetchAllInstances(false);
startManagePolling();
} else {
stopManagePolling();
}
if (menu === 'accounts' && isAdmin.value) {
await fetchConfig();
startAccountPolling();
} else {
stopAccountPolling();
}
if (menu === 'logs' && isAdmin.value) {
startLogPolling();
} else {
stopLogPolling();
}
if (menu === 'about' && isAdmin.value) {
await checkForUpdates({ silent: true });
} else if (!updatePending) {
stopUpdatePolling();
}
};
watch(currentMenu, syncCurrentMenu);
watch([currentMenu, currentSettingsTab, currentNotifyTab, currentLogTab], persistNavigationState);
watch(isAdmin, async (value) => {
if (value) {
if (bootstrappingAdmin) return;
initialWorkspaceLoading.value = true;
try {
await fetchConfig();
await syncCurrentMenu();
} finally {
initialWorkspaceLoading.value = false;
}
} else {
statusInstances.value = [];
initialWorkspaceLoading.value = false;
stopStatusPolling();
stopManagePolling();
stopAccountPolling();
}
});
watch(brandLogoUrl, (url) => {
updateFavicon(url);
}, { immediate: true });
watch(appBrandName, (name) => {
updateDocumentTitle(name);
}, { immediate: true });
watch(() => ecsCreateDraft.value.accountGroupKey, () => {
if (selectedCreateAccount.value) {
ecsCreateDraft.value.regionId = selectedCreateAccount.value.regionId || '';
}
resetDiskOptions();
scheduleLoadEcsDiskOptions();
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
});
watch(() => ecsCreateDraft.value.instanceType, () => {
resetDiskOptions();
scheduleLoadEcsDiskOptions();
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
});
watch(() => ecsCreateDraft.value.systemDiskCategory, () => {
const selected = ecsDiskCategoryOptions.value.find((item) => item.value === ecsCreateDraft.value.systemDiskCategory);
if (selected) {
ecsDiskHint.value = '来自阿里云 API。';
}
ecsCreatePreview.value = null;
ecsCreateConfirmed.value = false;
ecsCreateResult.value = null;
});
onMounted(() => {
setupMobileNavScroll();
checkInitStatus();
});
onUnmounted(() => {
teardownMobileNavScroll();
stopLogPolling();
stopStatusPolling();
stopManagePolling();
stopAccountPolling();
stopUpdatePolling();
stopUpdateSimulation();
if (updateReloadTimer) {
clearTimeout(updateReloadTimer);
updateReloadTimer = null;
}
if (diskOptionTimer) {
clearTimeout(diskOptionTimer);
}
});
return {
aliyunRegions,
allInstances,
config,
updateInfo,
updateState,
updateReloadPending,
updateScreenVisible,
updateScreenProgress,
updateScreenWaiting,
updateScreenCompleted,
hasAvailableUpdate,
hasVersionUpdate,
appBrandName,
brandLogoUrl,
cronWarning,
criticalError,
currentLogTab,
currentMenu,
mobileNavHidden,
currentSettingsTab,
currentNotifyTab,
accountDraft,
accountDraftTestResult,
accountModalMode,
billingDetails,
billingHistoryDays,
billingDetailsError,
billingDetailsLoading,
selectedBillingDate,
selectedBillingDay,
showBillingDetailsModal,
accountSyncResults,
accountSyncStates,
fetchingAllInstances,
syncingInstances,
filteredManagedInstances,
filteredStatusInstances,
initialized,
initialWorkspaceLoading,
isAdmin,
loading,
loadingCheckInit,
logAutoRefresh,
menuItems,
settingsItems,
settingsDirty,
passkeyAvailable,
passkeyCount,
passwordLoginEnabled,
passkeySupported,
passkeySupportHint,
passkeyBusy,
manageFilter,
accountOptions,
passwordInput,
reloadPage,
savingAccountModal,
testingAccountDraft,
ecsCreateConfirmed,
ecsCreateDraft,
ecsCreatePreview,
ecsCreateResult,
ecsCredentialResult,
showCredentialModal,
credentialSavedAck,
ecsCreating,
ecsOsOptions,
ecsDiskLoading,
ecsDiskCategoryOptions,
ecsDiskCategorySelectOptions,
ecsDiskHint,
ecsPreviewing,
showCreateEcsModal,
sendingEmail,
setupData,
backupPassword,
backupFile,
backupBusy,
showAccountModal,
showLoginModal,
statusFilter,
statusInstances,
systemLogs,
testingTelegram,
testingWebhook,
uploadingLogo,
checkingUpdate,
startingUpdate,
checkingLogin,
closeAccountModal,
closeBillingDetails,
dialogState,
formatBillingDate,
formatCurrencyValue,
formatBillingAmount,
billingUsagePresentation,
formatInstanceSpec,
formatPublicBandwidth,
formatPercent,
formatTrafficValue,
formatTrafficUsage,
diskUnitText,
diskCategoryText,
hasTrafficIssue,
trafficIssueText,
getAccountSyncResult,
getAccountSyncLabel,
getAccountSyncButtonClass,
getSiteTypeLabel,
isSyncingAccount,
isRefreshingInstance,
copyCredential,
buildCredentialCopyText,
resolveDialog,
openCreateAccountModal,
openBillingDay,
openBillingDetails,
backToBillingHistory,
openEditAccountModal,
openCreateEcsModal,
closeCreateEcsModal,
closeCredentialModal,
resetCreateEcsPreview,
previewCreateEcs,
confirmCreateEcs,
canReplaceIp,
saveAccountModal,
testAccountDraft,
statusClass,
statusText,
logTypeLabel,
syncAccount,
toasts,
getRegionName,
toggleAdmin,
performLogin,
loginWithPasskey,
registerPasskey,
performSetup,
fetchStatusData,
fetchAllInstances,
syncCloudInstances,
refreshInstanceCard,
controlInstance,
replaceInstanceIp,
deleteInstance,
removeAccountRow,
saveConfig,
createBackup,
restoreBackup,
uploadLogo,
clearLogo,
sendTestEmail,
sendTestTelegram,
sendTestWebhook,
fetchLogs,
clearLogs,
checkForUpdates,
handleUpdateAction,
startOnlineUpdate,
reloadBillingDetails
};
}
}).mount('#app');
</script>
</body>
</html>