1422 lines
45 KiB
HTML
1422 lines
45 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CatMata Sub2Proxy 控制面板</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
:root {
|
|
--bg-main: #0b0f19;
|
|
--bg-card: rgba(17, 24, 39, 0.7);
|
|
--bg-input: rgba(31, 41, 55, 0.5);
|
|
--border-color: rgba(255, 255, 255, 0.08);
|
|
--text-primary: #f3f4f6;
|
|
--text-secondary: #9ca3af;
|
|
--accent-purple: #8b5cf6;
|
|
--accent-pink: #ec4899;
|
|
--accent-gradient: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--font-main: 'Inter', system-ui, -apple-system, sans-serif;
|
|
--font-mono: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-main);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-main);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
background-image:
|
|
radial-gradient(circle at 10% 20%, rgba(139, 92, 246, 0.1) 0%, transparent 40%),
|
|
radial-gradient(circle at 90% 80%, rgba(236, 72, 153, 0.1) 0%, transparent 40%);
|
|
background-attachment: fixed;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
/* Glassmorphism Header */
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1.5rem 2rem;
|
|
background: var(--bg-card);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
margin-bottom: 2rem;
|
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
background: var(--accent-gradient);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.logo i {
|
|
color: var(--accent-purple);
|
|
-webkit-text-fill-color: initial;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
}
|
|
|
|
/* Tabs & Layout Grid */
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: 240px 1fr;
|
|
gap: 2rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.sidebar {
|
|
background: var(--bg-card);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 1.5rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.nav-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
padding: 0.85rem 1rem;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
border-radius: 10px;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.nav-btn:hover {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.nav-btn.active {
|
|
background: var(--accent-gradient);
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.main-content {
|
|
background: var(--bg-card);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 2rem;
|
|
min-height: 550px;
|
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
position: relative;
|
|
}
|
|
|
|
/* Buttons Style */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.65rem 1.25rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
border-radius: 10px;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--accent-gradient);
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
opacity: 0.9;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: var(--danger);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
/* Modal / Form Controls */
|
|
.form-group {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-family: var(--font-main);
|
|
font-size: 0.95rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: var(--accent-purple);
|
|
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
|
|
}
|
|
|
|
/* Panels Configuration */
|
|
.panel-title {
|
|
font-size: 1.35rem;
|
|
font-weight: 700;
|
|
margin-bottom: 1.5rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
/* Subscriptions View */
|
|
.subs-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.sub-card {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.sub-card:hover {
|
|
transform: translateY(-2px);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border-color: rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.sub-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.sub-name {
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.sub-type {
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.type-clash {
|
|
background: rgba(139, 92, 246, 0.15);
|
|
color: #a78bfa;
|
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
}
|
|
|
|
.type-singbox {
|
|
background: rgba(236, 72, 153, 0.15);
|
|
color: #f472b6;
|
|
border: 1px solid rgba(236, 72, 153, 0.3);
|
|
}
|
|
|
|
.sub-link {
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
word-break: break-all;
|
|
font-family: var(--font-mono);
|
|
max-height: 40px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.sub-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.75rem;
|
|
margin-top: auto;
|
|
border-top: 1px solid var(--border-color);
|
|
padding-top: 0.75rem;
|
|
}
|
|
|
|
/* Proxy Nodes view */
|
|
.table-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
text-align: left;
|
|
}
|
|
|
|
th {
|
|
padding: 1rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
td {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
tr:hover td {
|
|
background: rgba(255, 255, 255, 0.01);
|
|
}
|
|
|
|
.node-proto {
|
|
display: inline-block;
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.proto-hy2 { background: rgba(16, 185, 129, 0.15); color: #34d399; }
|
|
.proto-vless { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
|
|
.proto-tuic { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
|
.proto-vmess { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
|
|
.proto-trojan { background: rgba(236, 72, 153, 0.15); color: #f472b6; }
|
|
.proto-ss { background: rgba(107, 114, 128, 0.15); color: #9ca3af; }
|
|
|
|
/* Modal Drawer for Editing Nodes */
|
|
.drawer-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 100;
|
|
display: none;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.drawer {
|
|
width: 500px;
|
|
height: 100%;
|
|
background: #0f172a;
|
|
border-left: 1px solid var(--border-color);
|
|
padding: 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
overflow-y: auto;
|
|
transform: translateX(100%);
|
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.drawer-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.drawer-overlay.active .drawer {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.drawer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.drawer-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.drawer-close {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.drawer-close:hover {
|
|
color: white;
|
|
}
|
|
|
|
/* DNS Configuration styling */
|
|
.dns-tabs {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
|
|
.dns-tab-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
padding: 0.5rem 1rem;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.dns-tab-btn.active {
|
|
color: var(--accent-purple);
|
|
}
|
|
|
|
.dns-tab-btn.active::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -0.6rem;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 2px;
|
|
background: var(--accent-purple);
|
|
}
|
|
|
|
.editor-container {
|
|
position: relative;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.code-editor {
|
|
width: 100%;
|
|
height: 400px;
|
|
background: #090d16;
|
|
color: #34d399;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9rem;
|
|
padding: 1rem;
|
|
border: none;
|
|
resize: vertical;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.code-editor:focus {
|
|
outline: none;
|
|
}
|
|
|
|
/* Logger Terminal */
|
|
.console-panel {
|
|
margin-top: 2rem;
|
|
background: #090d16;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.console-header {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
padding: 0.75rem 1.5rem;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.console-logs {
|
|
padding: 1rem 1.5rem;
|
|
height: 120px;
|
|
overflow-y: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.85rem;
|
|
color: #a78bfa;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.log-line {
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.log-info { color: #f3f4f6; }
|
|
.log-success { color: #34d399; }
|
|
.log-error { color: #f87171; }
|
|
.log-warn { color: #fbbf24; }
|
|
|
|
/* Custom Switch / Checkbox */
|
|
.switch-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 44px;
|
|
height: 24px;
|
|
}
|
|
|
|
.switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: var(--bg-input);
|
|
transition: .4s;
|
|
border-radius: 24px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 16px;
|
|
width: 16px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background-color: white;
|
|
transition: .4s;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
input:checked + .slider {
|
|
background: var(--accent-gradient);
|
|
}
|
|
|
|
input:checked + .slider:before {
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
/* Inline Add / Edit Sub Form */
|
|
.sub-form-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 101;
|
|
display: none;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.sub-form-modal.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: #0f172a;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
width: 500px;
|
|
padding: 2rem;
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 0.75rem;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.2;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.75rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
/* Toast Element */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: #1f2937;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
padding: 1rem 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
transform: translateY(150%);
|
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.toast.active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.toast.success {
|
|
border-left: 4px solid var(--success);
|
|
}
|
|
|
|
.toast.error {
|
|
border-left: 4px solid var(--danger);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- Header -->
|
|
<header>
|
|
<div class="logo">
|
|
<i data-lucide="radio-tower"></i>
|
|
<span>CatMata Sub2Proxy</span>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn btn-secondary" onclick="fetchNodes()">
|
|
<i data-lucide="refresh-cw"></i>一键拉取 (Fetch)
|
|
</button>
|
|
<button class="btn btn-primary" onclick="saveAndGenerate()">
|
|
<i data-lucide="play-circle"></i>保存并生成配置 (Generate)
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Content Layout -->
|
|
<div class="layout">
|
|
<!-- Sidebar Navigation -->
|
|
<nav class="sidebar">
|
|
<button class="nav-btn active" onclick="switchTab('subs')" id="btn-tab-subs">
|
|
<i data-lucide="link"></i>订阅源管理
|
|
</button>
|
|
<button class="nav-btn" onclick="switchTab('proxies')" id="btn-tab-proxies">
|
|
<i data-lucide="shield"></i>代理节点列表
|
|
</button>
|
|
<button class="nav-btn" onclick="switchTab('dns')" id="btn-tab-dns">
|
|
<i data-lucide="globe"></i>DNS 规则配置
|
|
</button>
|
|
<button class="nav-btn" onclick="switchTab('history')" id="btn-tab-history">
|
|
<i data-lucide="history"></i>生成历史记录
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Main Panel -->
|
|
<main class="main-content">
|
|
<!-- Panel 1: Subscriptions -->
|
|
<section id="panel-subs">
|
|
<div class="panel-title">
|
|
<span>订阅源配置列表</span>
|
|
<button class="btn btn-primary" onclick="openSubModal()">
|
|
<i data-lucide="plus-circle"></i>添加订阅源
|
|
</button>
|
|
</div>
|
|
<div class="subs-grid" id="subs-list">
|
|
<!-- Dynamic Injection -->
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Panel 2: Proxy Nodes -->
|
|
<section id="panel-proxies" style="display: none;">
|
|
<div class="panel-title">
|
|
<span>已解析代理节点</span>
|
|
<span style="font-size: 0.9rem; font-weight: normal; color: var(--text-secondary);" id="proxies-counter">
|
|
共 0 个节点
|
|
</span>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>名称 (Tag)</th>
|
|
<th>协议 (Type)</th>
|
|
<th>服务器 (Server)</th>
|
|
<th>端口 (Port)</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="proxies-list">
|
|
<tr>
|
|
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
请先点击右上角 “一键拉取” 按钮解析节点
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Panel 3: DNS Configuration -->
|
|
<section id="panel-dns" style="display: none;">
|
|
<div class="panel-title">
|
|
<span>DNS 配置规则</span>
|
|
</div>
|
|
<div class="dns-tabs">
|
|
<button class="dns-tab-btn active" onclick="switchDnsTab('clash')" id="btn-dns-clash">Clash (Mihomo) DNS</button>
|
|
<button class="dns-tab-btn" onclick="switchDnsTab('singbox')" id="btn-dns-singbox">Sing-box DNS</button>
|
|
</div>
|
|
|
|
<!-- Clash Editor -->
|
|
<div id="dns-editor-clash" class="editor-container">
|
|
<textarea id="clash-dns-textarea" class="code-editor" spellcheck="false" placeholder="正在读取 Clash DNS 配置..."></textarea>
|
|
</div>
|
|
|
|
<!-- Sing-box Editor -->
|
|
<div id="dns-editor-singbox" class="editor-container" style="display: none;">
|
|
<textarea id="singbox-dns-textarea" class="code-editor" spellcheck="false" placeholder="正在读取 Sing-box DNS 配置..."></textarea>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Panel 4: History -->
|
|
<section id="panel-history" style="display: none;">
|
|
<div class="panel-title">
|
|
<span>配置生成历史记录</span>
|
|
<button class="btn btn-secondary" onclick="loadHistory()">
|
|
<i data-lucide="refresh-cw"></i>刷新历史
|
|
</button>
|
|
</div>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>生成时间</th>
|
|
<th>节点数量</th>
|
|
<th>本地下载</th>
|
|
<th>一键导入</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="history-list">
|
|
<tr>
|
|
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
暂无生成历史记录,运行生成配置后将在此归档
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Console Terminal -->
|
|
<div class="console-panel">
|
|
<div class="console-header">
|
|
<span>控制台日志 (Console Logs)</span>
|
|
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="clearLogs()">
|
|
清空日志
|
|
</button>
|
|
</div>
|
|
<div class="console-logs" id="console-logs">
|
|
<div class="log-line log-info">系统初始化完成。请添加订阅源或拉取配置。</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drawer Modal for editing a node's details -->
|
|
<div class="drawer-overlay" id="node-drawer-overlay">
|
|
<div class="drawer">
|
|
<div class="drawer-header">
|
|
<span class="drawer-title" id="drawer-node-title">编辑代理节点</span>
|
|
<button class="drawer-close" onclick="closeNodeDrawer()">×</button>
|
|
</div>
|
|
<div id="node-edit-form">
|
|
<!-- Dynamic fields populated on click -->
|
|
</div>
|
|
<div style="margin-top: auto; display: flex; gap: 1rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
|
<button class="btn btn-primary" style="flex: 1;" onclick="saveNodeEdit()">确认修改</button>
|
|
<button class="btn btn-secondary" onclick="closeNodeDrawer()">取消</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Subscription Form Modal -->
|
|
<div class="sub-form-modal" id="sub-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="sub-modal-title">添加订阅源</span>
|
|
<button class="drawer-close" onclick="closeSubModal()">×</button>
|
|
</div>
|
|
<form id="sub-form" onsubmit="saveSubForm(event)">
|
|
<input type="hidden" id="sub-edit-index">
|
|
<div class="form-group">
|
|
<label for="sub-name-input">名称 (Name)</label>
|
|
<input type="text" id="sub-name-input" class="form-control" required placeholder="例如: 机场A">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sub-link-input">订阅链接/本地路径 (Link / File Path)</label>
|
|
<input type="text" id="sub-link-input" class="form-control" required placeholder="http://... 或 C:\path\to\file.yaml">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sub-type-input">订阅格式 (Format)</label>
|
|
<select id="sub-type-input" class="form-control">
|
|
<option value="clash">Clash (YAML)</option>
|
|
<option value="singbox">Sing-box (JSON)</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeSubModal()">取消</button>
|
|
<button type="submit" class="btn btn-primary">保存</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Element -->
|
|
<div class="toast" id="toast">
|
|
<i data-lucide="info" id="toast-icon"></i>
|
|
<span id="toast-msg">提示信息</span>
|
|
</div>
|
|
|
|
<script>
|
|
// State management
|
|
let activeTab = 'subs';
|
|
let dnsTab = 'clash';
|
|
let subscriptions = [];
|
|
let proxies = []; // In-memory editable proxies
|
|
let originalDns = { clash: '', singbox: '' };
|
|
|
|
// On Load
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
lucide.createIcons();
|
|
loadSubscriptions();
|
|
loadDnsTemplates();
|
|
});
|
|
|
|
// Logging helper
|
|
function log(message, type = 'info') {
|
|
const logs = document.getElementById('console-logs');
|
|
const div = document.createElement('div');
|
|
div.className = `log-line log-${type}`;
|
|
div.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
|
logs.appendChild(div);
|
|
logs.scrollTop = logs.scrollHeight;
|
|
}
|
|
|
|
function clearLogs() {
|
|
document.getElementById('console-logs').innerHTML = '';
|
|
log('日志已清空。');
|
|
}
|
|
|
|
// Toast Notification helper
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.getElementById('toast');
|
|
const msg = document.getElementById('toast-msg');
|
|
const icon = document.getElementById('toast-icon');
|
|
|
|
toast.className = `toast active ${type}`;
|
|
msg.textContent = message;
|
|
|
|
let iconName = 'check-circle';
|
|
if (type === 'error') iconName = 'alert-triangle';
|
|
|
|
icon.setAttribute('data-lucide', iconName);
|
|
lucide.createIcons();
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('active');
|
|
}, 3000);
|
|
}
|
|
|
|
// Switch Panel Tabs
|
|
function switchTab(tab) {
|
|
activeTab = tab;
|
|
const panels = ['subs', 'proxies', 'dns', 'history'];
|
|
panels.forEach(p => {
|
|
document.getElementById(`panel-${p}`).style.display = p === tab ? 'block' : 'none';
|
|
document.getElementById(`btn-tab-${p}`).className = `nav-btn ${p === tab ? 'active' : ''}`;
|
|
});
|
|
log(`切换至 "${tab === 'subs' ? '订阅源管理' : tab === 'proxies' ? '代理节点' : tab === 'dns' ? 'DNS 规则' : '生成历史记录'}" 面板。`);
|
|
|
|
if (tab === 'history') {
|
|
loadHistory();
|
|
}
|
|
}
|
|
|
|
// Switch DNS Tab
|
|
function switchDnsTab(tab) {
|
|
dnsTab = tab;
|
|
document.getElementById('dns-editor-clash').style.display = tab === 'clash' ? 'block' : 'none';
|
|
document.getElementById('dns-editor-singbox').style.display = tab === 'singbox' ? 'block' : 'none';
|
|
|
|
document.getElementById('btn-dns-clash').className = `dns-tab-btn ${tab === 'clash' ? 'active' : ''}`;
|
|
document.getElementById('btn-dns-singbox').className = `dns-tab-btn ${tab === 'singbox' ? 'active' : ''}`;
|
|
}
|
|
|
|
// Subscriptions API interaction
|
|
async function loadSubscriptions() {
|
|
try {
|
|
const res = await fetch('/api/subs');
|
|
if (res.ok) {
|
|
subscriptions = await res.json();
|
|
renderSubscriptions();
|
|
} else {
|
|
log('读取订阅源配置失败。', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`读取订阅源异常: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function renderSubscriptions() {
|
|
const list = document.getElementById('subs-list');
|
|
list.innerHTML = '';
|
|
|
|
if (subscriptions.length === 0) {
|
|
list.innerHTML = `
|
|
<div style="grid-column: 1 / -1; text-align: center; padding: 3rem; color: var(--text-secondary); border: 1px dashed var(--border-color); border-radius: 12px;">
|
|
未配置订阅源。点击右上角“添加订阅源”开始配置。
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
subscriptions.forEach((sub, idx) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'sub-card';
|
|
card.innerHTML = `
|
|
<div class="sub-header">
|
|
<span class="sub-name">${sub.name}</span>
|
|
<span class="sub-type type-${sub.type}">${sub.type}</span>
|
|
</div>
|
|
<div class="sub-link" title="${sub.link}">${sub.link}</div>
|
|
<div class="sub-actions">
|
|
<button class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="openSubModal(${idx})">
|
|
<i data-lucide="edit-3" style="width: 14px; height: 14px;"></i>编辑
|
|
</button>
|
|
<button class="btn btn-danger" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="deleteSub(${idx})">
|
|
<i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>删除
|
|
</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(card);
|
|
});
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function deleteSub(idx) {
|
|
if (confirm(`确定要删除订阅源 "${subscriptions[idx].name}" 吗?`)) {
|
|
log(`正在删除订阅源: ${subscriptions[idx].name}`);
|
|
subscriptions.splice(idx, 1);
|
|
await saveSubscriptions();
|
|
}
|
|
}
|
|
|
|
async function saveSubscriptions() {
|
|
try {
|
|
const res = await fetch('/api/subs', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(subscriptions)
|
|
});
|
|
if (res.ok) {
|
|
log('订阅源已保存。', 'success');
|
|
showToast('订阅源保存成功');
|
|
renderSubscriptions();
|
|
} else {
|
|
log('保存订阅源失败。', 'error');
|
|
showToast('保存订阅源失败', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`保存订阅源异常: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Modal forms controls
|
|
function openSubModal(idx = null) {
|
|
const modal = document.getElementById('sub-modal');
|
|
const title = document.getElementById('sub-modal-title');
|
|
const form = document.getElementById('sub-form');
|
|
|
|
modal.classList.add('active');
|
|
if (idx !== null) {
|
|
title.textContent = '编辑订阅源';
|
|
document.getElementById('sub-edit-index').value = idx;
|
|
document.getElementById('sub-name-input').value = subscriptions[idx].name;
|
|
document.getElementById('sub-link-input').value = subscriptions[idx].link;
|
|
document.getElementById('sub-type-input').value = subscriptions[idx].type;
|
|
} else {
|
|
title.textContent = '添加订阅源';
|
|
form.reset();
|
|
document.getElementById('sub-edit-index').value = '';
|
|
}
|
|
}
|
|
|
|
function closeSubModal() {
|
|
document.getElementById('sub-modal').classList.remove('active');
|
|
}
|
|
|
|
async function saveSubForm(e) {
|
|
e.preventDefault();
|
|
const idx = document.getElementById('sub-edit-index').value;
|
|
const name = document.getElementById('sub-name-input').value.trim();
|
|
const link = document.getElementById('sub-link-input').value.trim();
|
|
const type = document.getElementById('sub-type-input').value;
|
|
|
|
const sub = { name, link, type };
|
|
|
|
if (idx !== '') {
|
|
subscriptions[idx] = sub;
|
|
log(`修改订阅源: ${name}`);
|
|
} else {
|
|
subscriptions.push(sub);
|
|
log(`添加新订阅源: ${name}`);
|
|
}
|
|
|
|
closeSubModal();
|
|
await saveSubscriptions();
|
|
}
|
|
|
|
// DNS templates API interaction
|
|
async function loadDnsTemplates() {
|
|
try {
|
|
const res = await fetch('/api/dns');
|
|
if (res.ok) {
|
|
const dnsData = await res.json();
|
|
originalDns = dnsData;
|
|
document.getElementById('clash-dns-textarea').value = dnsData.clash;
|
|
document.getElementById('singbox-dns-textarea').value = dnsData.singbox;
|
|
log('DNS 模板配置加载成功。');
|
|
} else {
|
|
log('加载 DNS 模板失败。', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`加载 DNS 模板异常: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Fetch and Parse subscriptions
|
|
async function fetchNodes() {
|
|
if (subscriptions.length === 0) {
|
|
showToast('请先配置订阅源!', 'error');
|
|
return;
|
|
}
|
|
log('开始拉取并解析所有订阅源节点,请稍候...');
|
|
try {
|
|
const res = await fetch('/api/fetch', { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
proxies = data.proxies;
|
|
log(`成功拉取 ${proxies.length} 个节点。`, 'success');
|
|
showToast(`成功解析到 ${proxies.length} 个节点`);
|
|
|
|
renderProxiesTable();
|
|
switchTab('proxies');
|
|
} else {
|
|
const errText = await res.text();
|
|
log(`拉取节点失败: ${errText}`, 'error');
|
|
showToast('拉取节点失败', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`拉取异常: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function renderProxiesTable() {
|
|
const list = document.getElementById('proxies-list');
|
|
const counter = document.getElementById('proxies-counter');
|
|
list.innerHTML = '';
|
|
|
|
counter.textContent = `共 ${proxies.length} 个节点`;
|
|
|
|
if (proxies.length === 0) {
|
|
list.innerHTML = `
|
|
<tr>
|
|
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
未提取到任何代理节点
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
proxies.forEach((node, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td style="font-weight: 500;">${node.name || node.tag}</td>
|
|
<td><span class="node-proto proto-${(node.type || '').toLowerCase()}">${node.type}</span></td>
|
|
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.server}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.port || node.server_port}</td>
|
|
<td>
|
|
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
|
|
<i data-lucide="sliders" style="width: 12px; height: 12px;"></i>修改参数
|
|
</button>
|
|
</td>
|
|
`;
|
|
list.appendChild(tr);
|
|
});
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Modal Drawer for Editing Nodes
|
|
let currentEditNodeIndex = null;
|
|
|
|
function openNodeDrawer(idx) {
|
|
currentEditNodeIndex = idx;
|
|
const node = proxies[idx];
|
|
const title = document.getElementById('drawer-node-title');
|
|
const container = document.getElementById('node-edit-form');
|
|
const overlay = document.getElementById('node-drawer-overlay');
|
|
|
|
title.textContent = `修改节点: ${node.name || node.tag}`;
|
|
container.innerHTML = '';
|
|
|
|
const commonFields = [
|
|
{ name: 'name', label: '节点 Tag/Name', val: node.name || node.tag, type: 'text' },
|
|
{ name: 'server', label: '服务器 IP/域名', val: node.server, type: 'text' },
|
|
{ name: 'port', label: '端口', val: node.port || node.server_port, type: 'number' }
|
|
];
|
|
|
|
commonFields.forEach(f => {
|
|
container.appendChild(createFormField(f.name, f.label, f.val, f.type));
|
|
});
|
|
|
|
const t = (node.type || '').toLowerCase();
|
|
if (t === 'vless') {
|
|
container.appendChild(createFormField('uuid', 'UUID', node.uuid, 'text'));
|
|
container.appendChild(createFormField('flow', 'Flow', node.flow || '', 'text'));
|
|
container.appendChild(createFormField('network', '传输协议 (Network)', node.network || 'tcp', 'text'));
|
|
if (node.tls) {
|
|
const tlsObj = typeof node.tls === 'object' ? node.tls : {};
|
|
const reality = tlsObj.reality || node['reality-opts'] || {};
|
|
container.appendChild(createFormField('sni', 'SNI/Server Name', tlsObj.server_name || node.servername || '', 'text'));
|
|
container.appendChild(createFormField('publicKey', 'Reality 公钥', reality.public_key || reality['public-key'] || '', 'text'));
|
|
container.appendChild(createFormField('shortId', 'Reality Short ID', reality.short_id || reality['short-id'] || '', 'text'));
|
|
}
|
|
} else if (t === 'tuic') {
|
|
container.appendChild(createFormField('uuid', 'UUID', node.uuid, 'text'));
|
|
container.appendChild(createFormField('password', '密码 (Password)', node.password, 'text'));
|
|
container.appendChild(createFormField('congestion', '拥塞控制 (Congestion)', node.congestion_control || node['congestion-controller'] || 'bbr', 'text'));
|
|
} else if (t === 'hysteria2') {
|
|
container.appendChild(createFormField('password', '密码 (Password)', node.password, 'text'));
|
|
container.appendChild(createFormField('up', '上传带宽 (Up)', node.up_mbps || node.up || '', 'text'));
|
|
container.appendChild(createFormField('down', '下载带宽 (Down)', node.down_mbps || node.down || '', 'text'));
|
|
container.appendChild(createFormField('sni', 'SNI', node.sni || (node.tls && node.tls.server_name) || '', 'text'));
|
|
} else if (t === 'vmess') {
|
|
container.appendChild(createFormField('uuid', 'UUID', node.uuid, 'text'));
|
|
container.appendChild(createFormField('alterId', 'Alter ID', node.alterId || node.alter_id || 0, 'number'));
|
|
if (node.network === 'ws' || (node.transport && node.transport.type === 'ws')) {
|
|
const trans = node.transport || {};
|
|
const wsOpts = node['ws-opts'] || {};
|
|
container.appendChild(createFormField('wsPath', 'Websocket 路径', trans.path || wsOpts.path || '', 'text'));
|
|
container.appendChild(createFormField('wsHost', 'Websocket Host', (trans.headers && trans.headers.Host) || (wsOpts.headers && wsOpts.headers.Host) || '', 'text'));
|
|
}
|
|
} else if (t === 'shadowsocks' || t === 'ss') {
|
|
container.appendChild(createFormField('cipher', '加密方式 (Cipher)', node.cipher || node.method, 'text'));
|
|
container.appendChild(createFormField('password', '密码 (Password)', node.password, 'text'));
|
|
}
|
|
|
|
overlay.classList.add('active');
|
|
}
|
|
|
|
function createFormField(name, label, value, type) {
|
|
const div = document.createElement('div');
|
|
div.className = 'form-group';
|
|
div.innerHTML = `
|
|
<label for="edit-${name}">${label}</label>
|
|
<input type="${type}" id="edit-${name}" class="form-control" value="${value !== undefined ? value : ''}">
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
function parseSpeed(speedStr) {
|
|
if (!speedStr) return null;
|
|
const m = String(speedStr).match(/(\d+)/);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
}
|
|
|
|
function closeNodeDrawer() {
|
|
document.getElementById('node-drawer-overlay').classList.remove('active');
|
|
currentEditNodeIndex = null;
|
|
}
|
|
|
|
function saveNodeEdit() {
|
|
if (currentEditNodeIndex === null) return;
|
|
const node = proxies[currentEditNodeIndex];
|
|
const t = (node.type || '').toLowerCase();
|
|
|
|
const newName = document.getElementById('edit-name').value.trim();
|
|
const newServer = document.getElementById('edit-server').value.trim();
|
|
const newPort = parseInt(document.getElementById('edit-port').value, 10);
|
|
|
|
if (!newName || !newServer || isNaN(newPort)) {
|
|
showToast('请正确填写必填字段!', 'error');
|
|
return;
|
|
}
|
|
|
|
node.name = newName;
|
|
node.tag = newName;
|
|
node.server = newServer;
|
|
node.port = newPort;
|
|
node.server_port = newPort;
|
|
|
|
if (t === 'vless') {
|
|
node.uuid = document.getElementById('edit-uuid').value;
|
|
node.flow = document.getElementById('edit-flow').value;
|
|
node.network = document.getElementById('edit-network').value;
|
|
|
|
if (node.tls) {
|
|
const sni = document.getElementById('edit-sni').value.trim();
|
|
const pubKey = document.getElementById('edit-publicKey').value.trim();
|
|
const shortId = document.getElementById('edit-shortId').value.trim();
|
|
|
|
if (typeof node.tls === 'object') {
|
|
node.tls.server_name = sni;
|
|
if (node.tls.reality) {
|
|
node.tls.reality.public_key = pubKey;
|
|
node.tls.reality.short_id = shortId;
|
|
}
|
|
}
|
|
node.servername = sni;
|
|
if (node['reality-opts']) {
|
|
node['reality-opts']['public-key'] = pubKey;
|
|
node['reality-opts']['short-id'] = shortId;
|
|
}
|
|
}
|
|
} else if (t === 'tuic') {
|
|
node.uuid = document.getElementById('edit-uuid').value;
|
|
node.password = document.getElementById('edit-password').value;
|
|
const cong = document.getElementById('edit-congestion').value;
|
|
node.congestion_control = cong;
|
|
node['congestion-controller'] = cong;
|
|
} else if (t === 'hysteria2') {
|
|
node.password = document.getElementById('edit-password').value;
|
|
const up = document.getElementById('edit-up').value;
|
|
const down = document.getElementById('edit-down').value;
|
|
const sni = document.getElementById('edit-sni').value;
|
|
|
|
node.up_mbps = parseSpeed(up);
|
|
node.up = up;
|
|
node.down_mbps = parseSpeed(down);
|
|
node.down = down;
|
|
node.sni = sni;
|
|
if (node.tls) node.tls.server_name = sni;
|
|
} else if (t === 'vmess') {
|
|
node.uuid = document.getElementById('edit-uuid').value;
|
|
const aid = parseInt(document.getElementById('edit-alterId').value, 10);
|
|
node.alterId = aid;
|
|
node.alter_id = aid;
|
|
|
|
if (node.network === 'ws' || (node.transport && node.transport.type === 'ws')) {
|
|
const path = document.getElementById('edit-wsPath').value;
|
|
const host = document.getElementById('edit-wsHost').value;
|
|
if (node.transport) {
|
|
node.transport.path = path;
|
|
if (!node.transport.headers) node.transport.headers = {};
|
|
node.transport.headers.Host = host;
|
|
}
|
|
if (node['ws-opts']) {
|
|
node['ws-opts'].path = path;
|
|
if (!node['ws-opts'].headers) node['ws-opts'].headers = {};
|
|
node['ws-opts'].headers.Host = host;
|
|
}
|
|
}
|
|
} else if (t === 'shadowsocks' || t === 'ss') {
|
|
const cipher = document.getElementById('edit-cipher').value;
|
|
node.cipher = cipher;
|
|
node.method = cipher;
|
|
node.password = document.getElementById('edit-password').value;
|
|
}
|
|
|
|
log(`更新代理节点属性: ${newName}`);
|
|
showToast('节点参数更新完毕');
|
|
closeNodeDrawer();
|
|
renderProxiesTable();
|
|
}
|
|
|
|
// Save configuration and trigger output generation
|
|
async function saveAndGenerate() {
|
|
if (proxies.length === 0) {
|
|
showToast('请先拉取并配置代理节点!', 'error');
|
|
return;
|
|
}
|
|
|
|
log('正在打包编辑好的配置文件与 DNS 规则,准备写入本地目录...');
|
|
const clashDns = document.getElementById('clash-dns-textarea').value;
|
|
const singboxDns = document.getElementById('singbox-dns-textarea').value;
|
|
|
|
const payload = {
|
|
proxies: proxies,
|
|
dns: {
|
|
clash: clashDns,
|
|
singbox: singboxDns
|
|
}
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (res.ok) {
|
|
log('配置文件生成完毕!已输出到 "out" 目录下。', 'success');
|
|
showToast('配置文件生成成功!');
|
|
if (activeTab === 'history') {
|
|
loadHistory();
|
|
}
|
|
} else {
|
|
const errText = await res.text();
|
|
log(`生成失败: ${errText}`, 'error');
|
|
showToast('生成配置文件失败', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`请求生成配置文件发生错误: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Load and render history
|
|
async function loadHistory() {
|
|
try {
|
|
const res = await fetch('/api/history');
|
|
if (res.ok) {
|
|
const list = await res.json();
|
|
renderHistoryList(list);
|
|
} else {
|
|
log('加载历史记录失败。', 'error');
|
|
}
|
|
} catch (e) {
|
|
log(`加载历史记录异常: ${e.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function renderHistoryList(list) {
|
|
const tbody = document.getElementById('history-list');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!list || list.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
暂无生成历史记录,运行生成配置后将在此归档
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const origin = window.location.origin;
|
|
|
|
list.forEach(item => {
|
|
const yamlDownloadUrl = `${origin}/api/download?file=${item.yamlFile}`;
|
|
const jsonDownloadUrl = `${origin}/api/download?file=${item.jsonFile}`;
|
|
|
|
// Construct client URI schemes
|
|
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlDownloadUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
|
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonDownloadUrl)}#${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td style="font-family: var(--font-mono); font-size: 0.9rem; font-weight: 500;">${item.timeStr}</td>
|
|
<td>${item.nodesCount} 个节点</td>
|
|
<td>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<a class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; text-decoration: none;" href="${yamlDownloadUrl}" target="_blank">
|
|
<i data-lucide="download" style="width: 12px; height: 12px;"></i>YAML
|
|
</a>
|
|
<a class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; text-decoration: none;" href="${jsonDownloadUrl}" target="_blank">
|
|
<i data-lucide="download" style="width: 12px; height: 12px;"></i>JSON
|
|
</a>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<a class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; text-decoration: none; background: linear-gradient(135deg, #10B981 0%, #059669 100%); box-shadow: 0 4px 10px rgba(16, 185, 129, 0.2);" href="${clashImportUrl}">
|
|
<i data-lucide="zap" style="width: 12px; height: 12px;"></i>导入 Clash
|
|
</a>
|
|
<a class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; text-decoration: none; background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%); box-shadow: 0 4px 10px rgba(59, 130, 246, 0.2);" href="${singboxImportUrl}">
|
|
<i data-lucide="zap" style="width: 12px; height: 12px;"></i>导入 Sing-box
|
|
</a>
|
|
</div>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
lucide.createIcons();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|