Files
ecs-controller/template.html

5473 lines
240 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="阿里云 流量抵扣服务 流量监控控制台,支持实例状态查看、实例管理、账号管理、系统设置与日志审计。">
<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">
<script>
// 自动跟随系统主题
(function() {
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<style>
:root {
--canvas: #f8fafc;
--panel: #ffffff;
--panel-strong: #ffffff;
--line: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--accent: #4f46e5;
--accent-strong: #4338ca;
--accent-light: #eef2ff;
--danger: #ef4444;
--warning: #f59e0b;
--success: #10b981;
--input-bg: #ffffff;
--glass-input-border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0f172a;
--panel: #1e293b;
--panel-strong: #334155;
--line: #334155;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #818cf8;
--accent-strong: #6366f1;
--accent-light: #312e81;
--danger: #f87171;
--warning: #fbbf24;
--success: #34d399;
--danger-bg: #7f1d1d;
--danger-text: #fca5a5;
--warning-bg: #78350f;
--warning-text: #fcd34d;
--success-bg: #064e3b;
--success-text: #6ee7b7;
--input-bg: #1e293b;
--glass-input-border: #475569;
--status-running-bg: #064e3b;
--status-running-text: #6ee7b7;
--status-stopped-bg: #7f1d1d;
--status-stopped-text: #fca5a5;
--status-transition-bg: #78350f;
--status-transition-text: #fcd34d;
--status-unknown-bg: #374151;
--status-unknown-text: #9ca3af;
--hover-bg: #334155;
}
}
[data-theme="light"] {
--canvas: #f8fafc;
--panel: #ffffff;
--panel-strong: #ffffff;
--line: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--accent: #4f46e5;
--accent-strong: #4338ca;
--accent-light: #eef2ff;
--danger: #ef4444;
--warning: #f59e0b;
--success: #10b981;
--input-bg: #ffffff;
--glass-input-border: #e5e7eb;
}
[data-theme="dark"] {
--canvas: #0f172a;
--panel: #1e293b;
--panel-strong: #334155;
--line: #334155;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #818cf8;
--accent-strong: #6366f1;
--accent-light: #312e81;
--danger: #f87171;
--warning: #fbbf24;
--success: #34d399;
--danger-bg: #7f1d1d;
--danger-text: #fca5a5;
--warning-bg: #78350f;
--warning-text: #fcd34d;
--success-bg: #064e3b;
--success-text: #6ee7b7;
--input-bg: #1e293b;
--glass-input-border: #475569;
--status-running-bg: #064e3b;
--status-running-text: #6ee7b7;
--status-stopped-bg: #7f1d1d;
--status-stopped-text: #fca5a5;
--status-transition-bg: #78350f;
--status-transition-text: #fcd34d;
--status-unknown-bg: #374151;
--status-unknown-text: #9ca3af;
--hover-bg: #334155;
}
/* 暗色模式 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);
}
@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: 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;
}
.glass-panel {
background: var(--panel);
border: 1px solid var(--line);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
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 rgba(79, 70, 229, 0.1);
}
.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 rgba(79, 70, 229, 0.1);
}
.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 {
border-radius: 12px;
padding: 12px 16px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.toast-card.info {
border-left: 3px solid var(--accent);
}
.toast-card.success {
border-left: 3px solid var(--success);
}
.toast-card.error {
border-left: 3px solid var(--danger);
}
.toast-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.toast-message {
margin-top: 4px;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text);
}
.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: 0.65rem;
width: 100%;
padding: 0.6rem 0.75rem;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
text-align: left;
font-weight: 500;
font-size: 0.875rem;
transition: background .12s ease, color .12s ease;
}
.menu-button:hover {
background: var(--hover-bg, #f3f4f6);
color: var(--text);
}
.menu-button.active {
background: var(--accent-light);
color: var(--accent);
font-weight: 600;
}
.menu-icon {
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--hover-bg, #f3f4f6);
color: var(--accent);
flex-shrink: 0;
}
.menu-button.active .menu-icon {
background: rgba(79, 70, 229, 0.12);
}
.menu-button:hover .menu-icon,
.menu-button.active .menu-icon {
color: var(--accent);
}
.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-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: #fff;
border: none;
}
.solid-button:hover {
background: var(--accent-strong);
}
.success-button {
background: var(--success-bg, #ecfdf5);
border: 1px solid var(--success-border, #d1fae5);
color: var(--success-text, #059669);
}
.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, #fffbeb);
border: 1px solid var(--warning-border, #fef3c7);
color: var(--warning-text, #d97706);
}
.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, #fef2f2);
border: 1px solid var(--danger-border, #fecaca);
color: var(--danger-text, #dc2626);
}
.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 {
color: #6b7280;
}
.sync-button.synced {
background: #ecfdf5;
border-color: #d1fae5;
color: #059669;
}
.sync-button.error {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.sync-note {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--muted);
}
.sync-note.error {
color: #dc2626;
}
.sync-note.success {
color: #059669;
}
.test-result-card {
margin-top: 18px;
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(226, 232, 240, 0.9);
background: rgba(248, 250, 252, 0.92);
display: grid;
gap: 10px;
}
.test-result-card.warning {
border-color: #fecaca;
background: #fff7f7;
}
.test-result-card.success {
border-color: #bbf7d0;
background: #f0fdf4;
}
.test-result-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
font-size: 13px;
}
.test-result-line strong {
color: #0f172a;
}
.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: #ecfdf5;
color: #059669;
border: 1px solid #d1fae5;
}
.test-result-badge.warning {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.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;
transition: background .12s ease;
}
.switch-row:hover {
background: var(--panel);
}
.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: var(--line);
transition: background .15s 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(--accent);
}
.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;
}
.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 rgba(79, 70, 229, 0.12);
}
.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;
}
.schedule-alert {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent);
border-radius: 18px;
background: color-mix(in srgb, var(--danger) 10%, var(--panel));
padding: 0.75rem 1rem;
}
.schedule-alert p {
margin: 0;
color: var(--danger);
font-size: 0.875rem;
font-weight: 700;
line-height: 1.55;
}
.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: nowrap;
width: 100%;
max-width: none;
margin: 0 auto;
}
.row-actions > button {
white-space: nowrap;
}
.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;
}
.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: 12px;
padding: 10px 12px 10px 16px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(18px);
animation: panel-fade-up .18s ease both;
}
.settings-save-float__hint {
font-size: 0.78rem;
font-weight: 700;
color: #64748b;
white-space: nowrap;
}
.account-card-grid {
display: grid;
gap: 8px;
margin-top: 14px;
color: #4b5563;
font-size: 0.875rem;
}
.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;
}
.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: 16px;
padding: 28px;
animation: modal-rise .2s ease both;
}
.login-gate-actions {
margin-top: 16px;
}
.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%, rgba(79, 70, 229, 0.08), transparent 30%),
radial-gradient(circle at 82% 12%, rgba(16, 185, 129, 0.08), transparent 26%),
var(--canvas);
}
.setup-card {
width: min(520px, 100%);
border-radius: 22px;
padding: 28px;
animation: modal-rise .22s ease both;
}
.setup-title {
margin-top: 8px;
font-size: 1.75rem;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.03em;
}
.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 {
border-radius: 14px;
padding: 20px 24px;
animation: panel-fade-up .25s ease both;
}
.page-header__row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.page-brand {
min-width: 0;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 12px;
}
.brand-logo {
width: 48px;
height: 48px;
flex: 0 0 48px;
border-radius: 16px;
object-fit: contain;
background: var(--panel);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.brand-logo-fallback {
width: 48px;
height: 48px;
flex: 0 0 48px;
border-radius: 16px;
background: #1f2937;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-weight: 900;
}
.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 #fef3c7;
background: #fffbeb;
color: #92400e;
padding: 12px 16px;
font-size: 0.875rem;
animation: panel-fade-up .25s ease both;
}
.workspace-grid {
margin-top: 24px;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 24px;
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: 4px;
}
.content-stack {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
align-self: start;
}
.mobile-bottom-nav {
display: none;
}
.section-panel {
border-radius: 14px;
padding: 20px 24px;
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;
}
.settings-grid {
margin-top: 24px;
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.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: 22px !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: #334155;
}
.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: #334155;
}
.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: 1279px) {
.status-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.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;
}
.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;
}
.mobile-bottom-nav {
position: fixed;
left: 14px;
right: 14px;
bottom: 14px;
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding: 10px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12);
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 4px;
border-radius: 18px;
color: #64748b;
transition: background .18s ease, color .18s ease, transform .18s ease;
}
.mobile-bottom-nav__button.active {
background: rgba(230, 244, 241, 0.95);
color: #0f172a;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.12);
}
.mobile-bottom-nav__button span:last-child {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.02em;
}
}
@media (max-width: 767px) {
.metric-card {
padding: 1rem;
}
.page-header,
.section-panel {
border-radius: 24px;
}
.glass-input,
.glass-select,
.glass-textarea {
padding: 0.75rem 0.85rem;
border-radius: 16px;
}
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: 16px;
right: 16px;
bottom: calc(96px + env(safe-area-inset-bottom));
justify-content: space-between;
border-radius: 20px;
}
.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;
}
}
</style>
</head>
<body>
<div id="app" v-cloak class="app-shell">
<div class="app-container">
<header v-if="initialized && !loadingCheckInit && !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>
<p class="subtle-label">云服务器流量管理</p>
<h1 class="text-2xl font-black tracking-tight md:text-3xl">ECS 服务器管理</h1>
</div>
</div>
<p class="max-w-3xl text-sm leading-6 text-slate-500" style="margin-top: 12px;">
将实例状态、实例管理、账号管理、系统设置和日志审计拆分到独立菜单,统一以账号组驱动实例同步,避免状态页和管理页数据漂移。
</p>
</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="mb-5 flex justify-center">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="页面 Logo" class="brand-logo login-brand-logo">
<div v-else class="brand-logo-fallback login-brand-logo">C</div>
</div>
<p class="subtle-label">管理员登录</p>
<h2 class="mt-2 text-2xl font-black">管理员登录</h2>
<p class="mt-3 text-sm leading-6 text-slate-500">登录后才可查看实例状态、实例管理、账号配置、系统设置和系统日志。</p>
<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>
<div v-if="initialized && !loadingCheckInit && !criticalError && isAdmin" class="workspace-grid">
<aside class="glass-panel sidebar-panel">
<div class="menu-stack">
<button v-for="item in menuItems" :key="item.key" @click="currentMenu = item.key"
class="menu-button" :class="{ active: currentMenu === item.key }">
<span class="menu-icon" v-html="item.icon"></span>
<span class="flex-1">{{ item.label }}</span>
</button>
</div>
</aside>
<main class="content-stack">
<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">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="subtle-label">备注</p>
<h3 class="mt-1 truncate text-lg font-bold">{{ item.remark || item.instanceName || item.instanceId }}</h3>
<p class="mt-1 text-xs text-[var(--muted)]">{{ item.accountLabel }}</p>
</div>
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
<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="mt-8 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div class="min-w-0">
<p class="text-[13px] text-[var(--muted)] mb-1">硬件系统</p>
<p class="text-[14px] text-slate-700 truncate" :title="item.osName">{{ item.osName || '-' }}</p>
</div>
<div class="min-w-0">
<p class="text-[13px] text-[var(--muted)] mb-1">规格</p>
<p class="text-[14px] text-slate-700 font-semibold">{{ item.cpu }} 核 {{ item.memory < 1024 ? item.memory + ' MiB' : (item.memory/1024).toFixed(1) + ' GiB' }}</p>
</div>
</div>
<div class="grid grid-cols-1">
<div class="min-w-0">
<p class="text-[13px] text-[var(--muted)] mb-1">公网 IP</p>
<p class="text-[14px] text-slate-700 font-mono">{{ isAdmin ? (item.publicIp || '-') : '登录后可见' }}</p>
</div>
</div>
</div>
<div class="mt-8">
<div class="flex items-end justify-between mb-3">
<span class="text-[12px] text-[var(--muted)]">流量消耗 ({{ item.percentageOfUse }}%)</span>
<span class="text-[15px] font-bold text-slate-800">
<template v-if="hasTrafficIssue(item)">
<span class="text-[#dc2626] text-[14px]">{{ trafficIssueText(item) }}</span>
</template>
<template v-else>
{{ formatTrafficValue(item.flow_used) }} <span class="text-[var(--muted)] font-normal text-[14px]">/ {{ item.flow_total }} GB</span>
</template>
</span>
</div>
<div class="progress-track h-2">
<div class="progress-fill h-full" :class="{ warning: item.rate95 }"
:style="{ width: Math.min(item.percentageOfUse, 100) + '%' }"></div>
</div>
<p v-if="hasTrafficIssue(item)" class="mt-2 text-xs text-[#dc2626]">{{ trafficIssueText(item) }}</p>
</div>
<div class="mt-6 flex items-center justify-between pt-4 text-xs text-[var(--muted)]">
<div 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: 'Unknown', label: '未知' }
]"
/>
<input v-model="manageFilter.search" class="glass-input" placeholder="搜索备注、实例名称或实例编号">
</div>
</div>
<div class="table-shell desktop-visible" 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-[980px]">
<div class="account-row summary-table-head" style="grid-template-columns: minmax(160px,1.25fr) 82px 70px minmax(120px,0.85fr) minmax(135px,0.95fr) minmax(115px,0.8fr) minmax(220px,1fr);">
<div>账号 / 实例</div>
<div>区域</div>
<div>状态</div>
<div>规格</div>
<div>地址</div>
<div class="center-column">出口流量</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(160px,1.25fr) 82px 70px minmax(120px,0.85fr) minmax(135px,0.95fr) minmax(115px,0.8fr) minmax(220px,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 v-if="inst.internetMaxBandwidthOut" class="summary-secondary">{{ inst.internetMaxBandwidthOut }} Mbps</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="account-usage">
<div class="account-usage__meta single">
<span v-if="hasTrafficIssue(inst)" class="text-[#dc2626]">{{ trafficIssueText(inst) }}</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>
<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>
</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"><span class="status-pill" :class="statusClass(inst.instanceStatus)">{{ statusText(inst.instanceStatus) }}</span></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">{{ inst.internetMaxBandwidthOut ? inst.internetMaxBandwidthOut + ' Mbps' : '-' }}</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 summary-table-head">
<div>备注</div>
<div>区域</div>
<div>站点</div>
<div class="center-column">账号流量</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">
<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>
</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 v-if="acc.scheduleBlockedByTraffic" @click="restoreScheduleBlock(idx)" class="ghost-button compact-button"
:disabled="isRestoringScheduleBlock(idx)">
{{ isRestoringScheduleBlock(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>区域:{{ getRegionName(acc.regionId) }}</div>
<div>站点:{{ getSiteTypeLabel(acc.siteType) }}</div>
<div>
账号流量:
<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">
<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">消费情况:本月消费 {{ formatCurrencyValue(acc.billing.monthly_cost, acc.billing.currency) }} / 余额 {{ formatCurrencyValue(acc.billing.balance, acc.billing.currency) }}</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 v-if="acc.scheduleBlockedByTraffic" @click="restoreScheduleBlock(idx)" class="ghost-button !px-3 !py-2"
:disabled="isRestoringScheduleBlock(idx)">
{{ isRestoringScheduleBlock(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-grid">
<div 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="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="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-[72px] !w-[72px] !rounded-[20px]">C</div>
<div class="min-w-[220px] flex-1 space-y-3">
<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>
<p class="text-xs leading-5 text-[var(--muted)]">支持 PNG、JPG、WebP上传文件小于 2MB。也可以直接填写图片 URL。</p>
</div>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-[#6f7787]">管理员密码</label>
<input v-model="config.admin_password" type="text" class="glass-input">
</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 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="rounded-[18px] border border-slate-200/70 bg-white/70 px-4 py-3 text-sm">
<div class="font-bold text-[#1f2937]">Cloudflare</div>
<div class="mt-1 text-xs leading-5 text-[var(--muted)]">当前仅支持 Cloudflare后续可扩展其他 DNS 服务商。</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 class="metric-card">
<div class="tab-pill-group">
<button @click="currentNotifyTab = 'email'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'email' }">邮件</button>
<button @click="currentNotifyTab = 'telegram'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'telegram' }">Telegram</button>
<button @click="currentNotifyTab = 'webhook'" class="ghost-button" :class="{ 'text-dark-button': currentNotifyTab === 'webhook' }">接口回调</button>
</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="接收会话编号">
<div class="rounded-[18px] border border-slate-200/70 bg-white/60 p-4">
<p class="text-sm font-bold text-[#0f172a]">Telegram 远程控制</p>
<p class="mt-1 text-xs leading-5 text-[#64748b]">配置 Telegram 通知后默认可通过 Bot 控制实例。发送 /start 打开按钮菜单,支持查看流量、开机和二次确认释放。</p>
<div class="mt-3 two-col-grid">
<input v-model="config.Notification.telegram.allowed_user_ids" class="glass-input" placeholder="允许控制用户 ID可选多个用逗号分隔">
<input v-model.number="config.Notification.telegram.confirm_ttl" type="number" min="30" class="glass-input" placeholder="释放确认有效期(秒)">
</div>
<p class="mt-2 text-xs text-[#94a3b8]">私聊 Bot 时可不填用户 ID群聊使用时建议填写避免群成员误操作。</p>
</div>
<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>
</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>
</main>
</div>
<nav v-if="initialized && !loadingCheckInit && !criticalError && isAdmin" class="mobile-bottom-nav">
<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="select-pop">
<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-title">{{ toast.type === 'error' ? '错误' : toast.type === 'success' ? '完成' : '提示' }}</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>
<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="mb-5 flex justify-center">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="页面 Logo" class="brand-logo login-brand-logo">
<div v-else class="brand-logo-fallback login-brand-logo">C</div>
</div>
<p class="subtle-label">管理员登录</p>
<h2 class="mt-2 text-2xl font-black">管理员登录</h2>
<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="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>
<div v-if="accountDraft.scheduleEnabled" class="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>
<div v-if="accountDraft.scheduleBlockedByTraffic" class="schedule-alert">
<p>该账号下实例曾因流量阈值触发自动停机,定时开关机会暂停;阿里云自然月重置后系统会自动恢复。</p>
<button v-if="accountModalMode === 'edit'" @click="restoreScheduleBlock(editingAccountIndex)" class="ghost-button compact-button"
:disabled="isRestoringScheduleBlock(editingAccountIndex)">
{{ isRestoringScheduleBlock(editingAccountIndex) ? '恢复中...' : '手动恢复' }}
</button>
</div>
</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="text-sm font-bold text-[#0f172a]">账号测试结果</p>
<p class="mt-1 text-xs text-[#64748b]">{{ 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="{ 'text-[#dc2626]': accountDraftTestResult.monitorStatus === 'warning' }">
{{ accountDraftTestResult.monitorMessage || '未测试' }}
</span>
</div>
<div class="test-result-line">
<strong>当前区域实例数</strong>
<span>{{ accountDraftTestResult.instanceCount ?? 0 }} 台</span>
</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="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="mb-4 flex justify-center">
<img v-if="brandLogoUrl" :src="brandLogoUrl" alt="页面 Logo" class="brand-logo">
<div v-else class="brand-logo-fallback">C</div>
</div>
<p class="subtle-label">初始化向导</p>
<h2 class="setup-title">初始化 ECS 服务器管理</h2>
<p class="setup-desc">首次使用时设置管理员密码、流量保护阈值和默认停机方式,后续可在系统设置中调整。</p>
<div class="setup-form">
<div>
<label class="field-label">管理员密码</label>
<input v-model="setupData.admin_password" type="password" class="glass-input" placeholder="请输入管理员密码">
</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"></script>
<script>
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: '',
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: {
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',
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 statusInstances = ref([]);
const allInstances = ref([]);
const systemLogs = ref([]);
const initialized = ref(true);
const loadingCheckInit = ref(true);
const loading = ref(true);
const checkingLogin = ref(true);
const showLoginModal = ref(false);
const passwordInput = ref('');
const criticalError = ref(null);
const isAdmin = ref(false);
const cronWarning = ref(false);
const currentMenu = ref('status');
const currentNotifyTab = ref('email');
const currentLogTab = ref('action');
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 fetchingAllInstances = ref(false);
const syncingInstances = ref(false);
const refreshingInstanceIds = ref([]);
const accountSyncStates = ref({});
const accountSyncResults = ref({});
const scheduleRestoreStates = 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 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({
admin_password: '',
traffic_threshold: 95,
shutdown_mode: 'KeepCharging'
});
const statusFilter = ref({ account: '', search: '' });
const manageFilter = ref({ account: '', region: '', status: '', search: '' });
let logInterval = null;
let statusInterval = null;
let manageInterval = null;
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 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 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 diskUnitText = (unit) => {
if (!unit || unit === 'GiB' || unit === 'GB') return 'GB';
return unit;
};
const diskCategoryText = (category) => ({
cloud_essd_entry: 'ESSD Entry 云盘',
cloud_essd: '增强型固态盘',
cloud_efficiency: '高效云盘',
cloud: '普通云盘'
}[category] || category || '-');
const brandLogoUrl = computed(() => (config.value.AppBrand?.logo_url || '').trim());
const updateFavicon = (url) => {
const icon = document.getElementById('app-favicon');
if (!icon) return;
const nextUrl = url || 'icon.png';
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 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
}));
});
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 isRestoringScheduleBlock = (index) => {
const account = config.value.Accounts[index];
if (!account) return false;
return !!scheduleRestoreStates.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 menuItems = computed(() => {
const items = [
{
key: 'status',
label: '实例状态',
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: '实例管理',
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: '账号管理',
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: '系统设置',
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: '系统日志',
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>'
}
);
}
return items;
});
const normalizeConfig = (payload) => {
const next = createDefaultConfig();
Object.assign(next, payload || {});
next.Notification = payload?.Notification || next.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 || '',
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);
markConfigClean();
} catch (error) {
console.error('Fetch config failed', error);
}
};
const fetchAllInstances = async (sync = false, options = {}) => {
if (fetchingAllInstances.value && options.silent) {
return;
}
fetchingAllInstances.value = true;
try {
const res = await fetch(`index.php?action=get_all_instances&sync=${sync ? '1' : '0'}`);
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=get_all_instances&sync=1');
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 startStatusPolling = () => {
stopStatusPolling();
if (!initialized.value || criticalError.value || currentMenu.value !== 'status') {
return;
}
const intervalSeconds = Math.max(Number(statusSyncInterval.value || 600), 60);
statusInterval = setInterval(() => {
fetchStatusData(false);
}, intervalSeconds * 1000);
};
const startManagePolling = () => {
stopManagePolling();
if (!initialized.value || criticalError.value || currentMenu.value !== 'manage' || !isAdmin.value) {
return;
}
const intervalSeconds = Math.max(Number(statusSyncInterval.value || 600), 60);
manageInterval = setInterval(() => {
fetchAllInstances(false, { silent: true });
}, intervalSeconds * 1000);
};
const validateAccounts = () => {
const accounts = config.value.Accounts || [];
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;
}
}
return true;
};
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 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) {
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 restoreScheduleBlock = async (index) => {
const account = config.value.Accounts[index];
if (!account) return;
if (!account.groupKey) {
openAlert('账号信息不完整,请先保存账号后再恢复。', 'error');
return;
}
const confirmed = await askConfirm('确定恢复该账号的定时开关机吗?如果本月流量仍然超过阈值,下一轮监控会再次触发保护并暂停定时任务。', {
title: '恢复定时开关机',
confirmLabel: '确认恢复'
});
if (!confirmed) return;
const key = getAccountKey(account, index);
scheduleRestoreStates.value = { ...scheduleRestoreStates.value, [key]: true };
try {
const res = await fetch('index.php?action=restore_schedule_block', {
method: 'POST',
body: JSON.stringify({ groupKey: account.groupKey })
});
const data = await res.json();
if (!res.ok || !data.success) {
openAlert(data.message || '恢复失败', 'error');
return;
}
await fetchConfig();
if (accountModalMode.value === 'edit' && editingAccountIndex.value > -1) {
accountDraft.value = {
...createDefaultAccount(),
...(config.value.Accounts[editingAccountIndex.value] || {})
};
}
openAlert(data.message || '定时开关机已恢复', 'success');
} catch (error) {
console.error('Restore schedule failed', error);
openAlert('恢复请求失败,请稍后重试', 'error');
} finally {
scheduleRestoreStates.value = { ...scheduleRestoreStates.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 validateAccountDraft = () => {
const draft = accountDraft.value;
if (!draft.remark || !draft.AccessKeyId || !draft.AccessKeySecret || !draft.regionId || !draft.maxTraffic) {
openAlert('请填写完整的账号信息包括备注、AK、区域和账号流量。', '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 (!draft.scheduleEnabled) {
draft.scheduleStartEnabled = false;
draft.scheduleStopEnabled = false;
draft.startTime = '';
draft.stopTime = '';
}
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;
}
ecsCreateResult.value = data.data || {};
ecsCredentialResult.value = data.data || {};
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 performLogin = async () => {
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',
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();
isAdmin.value = !!data.logged_in;
if (isAdmin.value) {
await fetchConfig();
}
} 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;
}
config.value.AppBrand.logo_url = data.brand?.logo_url || '';
initialized.value = !!data.initialized;
if (initialized.value) {
await checkLoginStatus();
if (isAdmin.value) {
await fetchStatusData();
} else {
loading.value = false;
}
} else {
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';
return 'status-unknown';
};
const statusText = (status) => ({
Running: '运行中',
Stopped: '已停止',
Released: '已释放',
Releasing: '释放中',
Starting: '启动中',
Stopping: '停止中',
Pending: '创建中',
Unknown: '未知'
}[status] || '未知');
const logTypeLabel = (type) => ({
info: '信息',
warning: '提醒',
error: '错误',
heartbeat: '检测'
}[type] || '信息');
const reloadPage = () => {
window.location.reload();
};
watch(currentMenu, async (menu) => {
if (menu === 'status' && isAdmin.value) {
await fetchStatusData();
} else {
stopStatusPolling();
}
if (menu === 'manage' && isAdmin.value) {
await fetchAllInstances(false);
startManagePolling();
} else {
stopManagePolling();
}
if (menu === 'logs' && isAdmin.value) {
startLogPolling();
} else {
stopLogPolling();
}
});
watch(isAdmin, async (value) => {
if (value) {
await fetchConfig();
await fetchStatusData();
} else {
statusInstances.value = [];
stopStatusPolling();
stopManagePolling();
}
});
watch(brandLogoUrl, (url) => {
updateFavicon(url);
}, { 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(() => {
checkInitStatus();
});
onUnmounted(() => {
stopLogPolling();
stopStatusPolling();
stopManagePolling();
if (diskOptionTimer) {
clearTimeout(diskOptionTimer);
}
});
return {
aliyunRegions,
allInstances,
config,
brandLogoUrl,
cronWarning,
criticalError,
currentLogTab,
currentMenu,
currentNotifyTab,
accountDraft,
accountDraftTestResult,
accountModalMode,
accountSyncResults,
accountSyncStates,
scheduleRestoreStates,
fetchingAllInstances,
syncingInstances,
filteredManagedInstances,
filteredStatusInstances,
initialized,
isAdmin,
loading,
loadingCheckInit,
logAutoRefresh,
menuItems,
settingsDirty,
manageFilter,
accountOptions,
passwordInput,
reloadPage,
savingAccountModal,
testingAccountDraft,
ecsCreateConfirmed,
ecsCreateDraft,
ecsCreatePreview,
ecsCreateResult,
ecsCredentialResult,
showCredentialModal,
credentialSavedAck,
ecsCreating,
ecsOsOptions,
ecsDiskLoading,
ecsDiskCategoryOptions,
ecsDiskCategorySelectOptions,
ecsDiskHint,
ecsPreviewing,
showCreateEcsModal,
sendingEmail,
setupData,
showAccountModal,
showLoginModal,
statusFilter,
statusInstances,
systemLogs,
testingTelegram,
testingWebhook,
uploadingLogo,
checkingLogin,
closeAccountModal,
dialogState,
formatCurrencyValue,
formatTrafficValue,
formatTrafficUsage,
diskUnitText,
diskCategoryText,
hasTrafficIssue,
trafficIssueText,
getAccountSyncResult,
getAccountSyncLabel,
getAccountSyncButtonClass,
getSiteTypeLabel,
isSyncingAccount,
isRestoringScheduleBlock,
isRefreshingInstance,
copyCredential,
buildCredentialCopyText,
resolveDialog,
openCreateAccountModal,
openEditAccountModal,
openCreateEcsModal,
closeCreateEcsModal,
closeCredentialModal,
resetCreateEcsPreview,
previewCreateEcs,
confirmCreateEcs,
canReplaceIp,
saveAccountModal,
testAccountDraft,
statusClass,
statusText,
logTypeLabel,
syncAccount,
restoreScheduleBlock,
toasts,
getRegionName,
toggleAdmin,
performLogin,
performSetup,
fetchStatusData,
fetchAllInstances,
syncCloudInstances,
refreshInstanceCard,
controlInstance,
replaceInstanceIp,
deleteInstance,
removeAccountRow,
saveConfig,
uploadLogo,
clearLogo,
sendTestEmail,
sendTestTelegram,
sendTestWebhook,
fetchLogs,
clearLogs
};
}
}).mount('#app');
</script>
</body>
</html>