Files
sub2proxy/public/index.html
T
2026-06-13 16:19:52 +08:00

1527 lines
49 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>代理名</th>
<th>协议 (Type)</th>
<th>服务器 (Server)</th>
<th>端口 (Port)</th>
<th>操作</th>
</tr>
</thead>
<tbody id="proxies-list">
<tr>
<td colspan="6" 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>
<th>操作</th>
</tr>
</thead>
<tbody id="history-list">
<tr>
<td colspan="5" 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()">&times;</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()">&times;</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: '' };
let appConfig = { publicBaseUrl: '' };
// On Load
window.addEventListener('DOMContentLoaded', async () => {
lucide.createIcons();
await loadAppConfig();
loadSubscriptions();
loadDnsTemplates();
});
async function loadAppConfig() {
try {
const res = await fetch('/api/app-config');
if (res.ok) {
const data = await res.json();
appConfig.publicBaseUrl = (data.publicBaseUrl || '').replace(/\/+$/, '');
}
} catch (e) {
console.warn('Failed to load app config:', e);
}
}
// 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 escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[ch]);
}
function getParsedName(node) {
return node.name || node.tag || '';
}
function getProxyName(node) {
return node.proxyName || node.originalName || getParsedName(node);
}
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="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
未提取到任何代理节点
</td>
</tr>
`;
return;
}
proxies.forEach((node, idx) => {
const tr = document.createElement('tr');
const parsedName = getParsedName(node);
const proxyName = getProxyName(node);
const nodeType = node.type || '';
const server = node.server || '';
const port = node.port || node.server_port || '';
tr.innerHTML = `
<td style="font-weight: 500;" title="${escapeHtml(parsedName)}">${escapeHtml(parsedName)}</td>
<td title="${escapeHtml(proxyName)}">${escapeHtml(proxyName)}</td>
<td><span class="node-proto proto-${escapeHtml(nodeType.toLowerCase())}">${escapeHtml(nodeType)}</span></td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(server)}</td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(port)}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<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>
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteProxy(${idx})">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
</button>
</div>
</td>
`;
list.appendChild(tr);
});
lucide.createIcons();
}
function deleteProxy(idx) {
const nodeName = getParsedName(proxies[idx]);
if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) {
log(`在内存中删除代理节点: ${nodeName}`);
proxies.splice(idx, 1);
renderProxiesTable();
showToast('代理节点已删除');
}
}
// 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 = `修改节点: ${getParsedName(node)}`;
container.innerHTML = '';
const commonFields = [
{ name: 'name', label: '解析名 (Tag)', val: getParsedName(node), type: 'text' },
{ name: 'proxyName', label: '代理名', val: getProxyName(node), type: 'text', readonly: true },
{ 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, { readonly: f.readonly }));
});
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, options = {}) {
const div = document.createElement('div');
div.className = 'form-group';
const readonlyAttr = options.readonly ? ' readonly aria-readonly="true"' : '';
div.innerHTML = `
<label for="edit-${escapeHtml(name)}">${escapeHtml(label)}</label>
<input type="${escapeHtml(type)}" id="edit-${escapeHtml(name)}" class="form-control" value="${escapeHtml(value !== undefined ? value : '')}"${readonlyAttr}>
`;
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');
}
}
async function deleteHistory(yamlFile, jsonFile, timeStr) {
if (confirm(`确定要删除该历史生成记录吗?\n生成时间: ${timeStr}\n(注意:此操作将物理删除对应的配置文件归档且不可逆!)`)) {
log(`正在请求删除历史记录: ${timeStr}`);
try {
const res = await fetch('/api/history/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yamlFile, jsonFile })
});
if (res.ok) {
log(`成功删除历史记录: ${timeStr}`, 'success');
showToast('历史记录删除成功');
loadHistory();
} else {
const errText = await res.text();
log(`删除历史记录失败: ${errText}`, 'error');
showToast('删除历史记录失败', 'error');
}
} catch (e) {
log(`删除历史记录异常: ${e.message}`, 'error');
}
}
}
function getClientImportOrigin() {
if (appConfig.publicBaseUrl) {
return appConfig.publicBaseUrl;
}
const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
if (!localHosts.has(window.location.hostname)) {
return window.location.origin;
}
const port = window.location.port ? `:${window.location.port}` : '';
return `${window.location.protocol}//127.0.0.1${port}`;
}
function renderHistoryList(list) {
const tbody = document.getElementById('history-list');
tbody.innerHTML = '';
if (!list || list.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
暂无生成历史记录,运行生成配置后将在此归档
</td>
</tr>
`;
return;
}
const origin = window.location.origin;
const clientOrigin = getClientImportOrigin();
list.forEach(item => {
const yamlFileParam = encodeURIComponent(item.yamlFile);
const jsonFileParam = encodeURIComponent(item.jsonFile);
const yamlDownloadUrl = `${origin}/api/download?file=${yamlFileParam}`;
const jsonDownloadUrl = `${origin}/api/download?file=${jsonFileParam}`;
const yamlImportProfileUrl = `${clientOrigin}/api/profile?file=${yamlFileParam}`;
const jsonImportProfileUrl = `${clientOrigin}/api/profile?file=${jsonFileParam}`;
// Construct client URI schemes
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlImportProfileUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonImportProfileUrl)}#${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>
<td>
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteHistory('${item.yamlFile}', '${item.jsonFile}', '${item.timeStr}')">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
</button>
</td>
`;
tbody.appendChild(tr);
});
lucide.createIcons();
}
</script>
</body>
</html>