mirror of
https://github.com/Kori1c/ecs-controller.git
synced 2026-05-07 14:16:13 +08:00
5473 lines
240 KiB
HTML
5473 lines
240 KiB
HTML
<!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>
|