feat: SOCKS5 代理管理系统 v1.0

Flask + SQLite + Bootstrap5 暗色主题
- 仪表盘: 在线/离线统计 + 最近代理 + 操作日志
- 代理 CRUD: 增删改 + 启用/禁用 + 单/批量健康检测(SOCKS5)
- 分组管理: 创建/删除/筛选
- 操作日志: 分页查询
- RESTful API + Web 界面
- 管理员登录认证 (SM_ADMIN_PASSWORD 环境变量)
This commit is contained in:
Your Name
2026-07-16 16:40:20 +08:00
commit aa8af6c2e0
20 changed files with 950 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}SOCKS Manager{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body { background:#0f1117; color:#e2e8f0; }
.card { background:#1a1d27; border:1px solid #2a2d3a; }
.navbar { background:#151822 !important; border-bottom:1px solid #2a2d3a !important; }
.navbar-brand { color:#818cf8 !important; font-weight:700; }
.nav-link { color:#a0aec0 !important; }
.nav-link:hover, .nav-link.active { color:#818cf8 !important; }
.table { color:#e2e8f0; }
.table-dark { background:#1a1d27; }
.form-control, .form-select { background:#0f1117; border-color:#2a2d3a; color:#e2e8f0; }
.form-control:focus, .form-select:focus { background:#0f1117; border-color:#818cf8; color:#e2e8f0; }
.badge-online { background:#10b981; }
.badge-offline { background:#ef4444; }
.badge-unknown { background:#6b7280; }
.badge-error { background:#f59e0b; }
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; }
.dot-online { background:#10b981; }
.dot-offline { background:#ef4444; }
.dot-unknown { background:#6b7280; }
.dot-error { background:#f59e0b; }
.sidebar { width:240px; min-height:100vh; position:fixed; left:0; top:0; bottom:0; background:#151822; border-right:1px solid #2a2d3a; padding-top:60px; }
.sidebar .nav-link { padding:.6rem 1.2rem; border-radius:.4rem; margin:.2rem .8rem; }
.sidebar .nav-link:hover, .sidebar .nav-link.active { background:#1e2235; }
.main-content { margin-left:240px; padding:1.5rem; }
@media (max-width:768px) { .sidebar { display:none; } .main-content { margin-left:0; } }
.stat-card .stat-value { font-size:2rem; font-weight:700; }
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1060; }
</style>
{% block extra_style %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/"><i class="bi bi-hdd-rack"></i> SOCKS Manager</a>
<div class="d-flex align-items-center">
<span class="text-muted small me-3">{{ g.user if g and g.user else 'admin' }}</span>
<a href="/logout" class="btn btn-outline-secondary btn-sm">退出</a>
</div>
</div>
</nav>
<div class="sidebar">
<div class="nav flex-column">
<a class="nav-link {% if 'dashboard' in request.endpoint|default('') %}active{% endif %}" href="/dashboard">
<i class="bi bi-speedometer2"></i> 仪表盘</a>
<a class="nav-link {% if 'proxy' in request.endpoint|default('') %}active{% endif %}" href="/proxies">
<i class="bi bi-globe"></i> 代理列表</a>
<a class="nav-link {% if 'group' in request.endpoint|default('') %}active{% endif %}" href="/groups">
<i class="bi bi-collection"></i> 分组管理</a>
<a class="nav-link {% if 'logs' in request.endpoint|default('') %}active{% endif %}" href="/logs">
<i class="bi bi-journal-text"></i> 日志</a>
</div>
</div>
<div class="main-content">
<div class="toast-container" id="toastContainer"></div>
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function toast(msg, type='info') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast show align-items-center text-bg-${type} border-0`;
t.setAttribute('role','alert');
t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
c.appendChild(t);
setTimeout(()=>t.remove(), 4000);
}
async function apiFetch(url, opts={}) {
const r = await fetch(url, {...opts, headers:{'Content-Type':'application/json', ...opts.headers}});
return r.json();
}
</script>
{% block extra_script %}{% endblock %}
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}仪表盘 · SOCKS Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-speedometer2"></i> 仪表盘</h4>
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card stat-card p-3">
<div class="text-muted small">代理总数</div>
<div class="stat-value">{{ total }}</div>
</div></div>
<div class="col-md-3"><div class="card stat-card p-3">
<div class="text-muted small"><span class="status-dot dot-online"></span>在线</div>
<div class="stat-value text-success">{{ online }}</div>
</div></div>
<div class="col-md-3"><div class="card stat-card p-3">
<div class="text-muted small"><span class="status-dot dot-offline"></span>离线</div>
<div class="stat-value text-danger">{{ offline }}</div>
</div></div>
<div class="col-md-3"><div class="card stat-card p-3">
<div class="text-muted small">分组数</div>
<div class="stat-value">{{ groups }}</div>
</div></div>
</div>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-globe"></i> 最近活跃代理</span>
<button class="btn btn-outline-primary btn-sm" onclick="checkAll()">
<i class="bi bi-arrow-clockwise"></i> 全部检测</button>
</div>
<div class="card-body p-0">
{% if proxies %}
<table class="table table-dark table-hover mb-0">
<thead><tr><th>名称</th><th>地址</th><th>状态</th><th>延迟</th><th>最后检测</th></tr></thead>
<tbody>
{% for p in proxies %}
<tr>
<td>{{ p.name }}</td>
<td><code>{{ p.host }}:{{ p.port }}</code></td>
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}</td>
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
<td>{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-4 text-muted">暂无代理,<a href="/proxies/add" class="text-info">添加一个</a></div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header"><span><i class="bi bi-journal-text"></i> 最近操作日志</span></div>
<div class="card-body p-0">
<table class="table table-dark mb-0 table-sm">
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
<tbody>
{% for l in recent %}
<tr>
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
<td>{{ l.action }}</td>
<td>{{ l.target or '-' }}</td>
<td>{{ l.detail or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
async function checkAll() {
const btn = event.currentTarget;
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 检测中...';
try {
const r = await apiFetch('/health/check-all');
toast(`检测完成: ${r.checked} 个, ${r.online} 在线, ${r.offline} 离线`, r.offline?'warning':'success');
} catch(e) { toast('检测失败: '+e.message, 'danger'); }
btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> 全部检测';
}
</script>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}分组管理 · SOCK Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-collection"></i> 分组管理</h4>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addGroupModal">
<i class="bi bi-plus-lg"></i> 添加分组</button>
</div>
<div class="row g-3">
{% for g in groups %}
<div class="col-md-4 col-lg-3">
<div class="card" style="border-left:4px solid {{ g.color }}">
<div class="card-body">
<h6 style="color:{{ g.color }}">{{ g.name }}</h6>
<p class="text-muted small mb-1">{{ g.description or '无描述' }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-secondary">{{ g.proxies|length }} 个代理</span>
<button class="btn btn-outline-danger btn-sm" onclick="deleteGroup({{ g.id }}, '{{ g.name }}')">
<i class="bi bi-trash"></i></button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not groups %}
<div class="text-center py-5 text-muted">暂无分组</div>
{% endif %}
<!-- Add Group Modal -->
<div class="modal fade" id="addGroupModal">
<div class="modal-dialog modal-sm">
<div class="modal-content" style="background:#1a1d27;border-color:#2a2d3a">
<div class="modal-header" style="border-color:#2a2d3a">
<h6 class="modal-title">添加分组</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">名称</label>
<input id="gName" class="form-control" required></div>
<div class="mb-3"><label class="form-label">颜色</label>
<input id="gColor" type="color" class="form-control form-control-color" value="#6366f1"></div>
<div class="mb-3"><label class="form-label">描述</label>
<input id="gDesc" class="form-control"></div>
</div>
<div class="modal-footer" style="border-color:#2a2d3a">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-sm btn-primary" onclick="addGroup()">添加</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
async function addGroup() {
const name = document.getElementById('gName').value.trim();
if (!name) return;
try {
await fetch('/groups/add', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:`name=${encodeURIComponent(name)}&color=${document.getElementById('gColor').value}&description=${encodeURIComponent(document.getElementById('gDesc').value)}`});
toast('分组已添加', 'success'); location.reload();
} catch(e) { toast('添加失败', 'danger'); }
}
async function deleteGroup(id, name) {
if (!confirm(`确认删除分组 "${name}"`)) return;
const r = await fetch(`/groups/${id}`, {method:'DELETE'});
const j = await r.json();
if (j.error) { toast(j.error, 'warning'); } else { toast('分组已删除', 'success'); location.reload(); }
}
</script>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}日志 · SOCKS Manager{% endblock %}
{% block content %}
<h4 class="mb-4"><i class="bi bi-journal-text"></i> 操作日志</h4>
<div class="card">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0 table-sm">
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
<tbody>
{% for l in pages.items %}
<tr>
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
<td>{{ l.action }}</td>
<td>{{ l.target or '-' }}</td>
<td>{{ l.detail or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center" style="border-color:#2a2d3a">
<span class="text-muted small">共 {{ pages.total }} 条</span>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pages.has_prev %}
<li class="page-item"><a class="page-link" href="?page={{ pages.prev_num }}" style="color:#818cf8">上一页</a></li>
{% endif %}
{% for n in range(page_start, page_end + 1) %}
<li class="page-item {% if n==pages.page %}active{% endif %}">
<a class="page-link" href="?page={{ n }}" style="color:#818cf8;background:{{ 'transparent' if n!=pages.page else '#818cf8' }};border-color:#2a2d3a">{{ n }}</a></li>
{% endfor %}
{% if pages.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ pages.next_num }}" style="color:#818cf8">下一页</a></li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}代理列表 · SOCKS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-globe"></i> 代理列表</h4>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="groupFilter" style="width:180px"
onchange="location.href='/proxies?group='+this.value">
<option value="">全部分组</option>
{% for g in groups %}
<option value="{{ g.id }}" {% if active_group|default('') == g.id|string %}selected{% endif %}>
{{ g.name }}</option>
{% endfor %}
</select>
<a href="/proxies/add" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> 添加代理</a>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-dark table-hover align-middle mb-0">
<thead><tr><th>名称</th><th>主机</th><th>端口</th><th>认证</th><th>分组</th><th>状态</th><th>延迟</th><th>操作</th></tr></thead>
<tbody>
{% for p in proxies %}
<tr class="{% if not p.enabled %}table-secondary{% endif %}">
<td><a href="/proxies/{{ p.id }}/edit" class="text-decoration-none">{{ p.name }}</a></td>
<td><code>{{ p.host }}</code></td>
<td>{{ p.port }}</td>
<td>{{ '有认证' if p.username else '无' }}</td>
<td>{% if p.group %}<span style="color:{{ p.group.color }}">{{ p.group.name }}</span>{% else %}-{% endif %}</td>
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}<br><small class="text-muted">{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '未检测' }}</small></td>
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/proxies/{{ p.id }}/edit" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i></a>
<a href="/proxies/toggle/{{ p.id }}" class="btn btn-outline-{{ 'secondary' if p.enabled else 'success' }}" onclick="return confirm('确认{{ '禁用' if p.enabled else '启用' }}?')">
<i class="bi bi-{{ 'eye-slash' if p.enabled else 'eye' }}"></i></a>
<a href="/proxies/{{ p.id }}" class="btn btn-outline-danger" onclick="return confirm('确认删除?')"><i class="bi bi-trash"></i></a>
<button class="btn btn-outline-info" onclick="checkOne({{ p.id }})"><i class="bi bi-arrow-clockwise"></i></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not proxies %}
<div class="text-center py-5 text-muted">暂无代理</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
async function checkOne(pid) {
try {
const r = await apiFetch(`/api/proxies/${pid}/check`, {method:'POST'});
toast(`${r.name}: ${r.status}${r.latency_ms? ' ('+r.latency_ms+'ms)':''}`, r.status==='online'?'success':'danger');
location.reload();
} catch(e) { toast('检测失败', 'danger'); }
}
</script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ '编辑' if action=='edit' else '添加' }}代理 · SOCKS Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-{{ 'pencil' if action=='edit' else 'plus-circle' }}"></i>
{{ '编辑代理' if action=='edit' else '添加代理' }}</h4>
<a href="/proxies" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left"></i> 返回</a>
</div>
<div class="card" style="max-width:640px">
<div class="card-body">
<form method="post">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">名称</label>
<input name="name" class="form-control" required value="{{ p.name if p else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">分组</label>
<select name="group_id" class="form-select">
<option value=""></option>
{% for g in groups %}
<option value="{{ g.id }}" {% if p and p.group and p.group.id==g.id %}selected{% endif %}>
{{ g.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-8">
<label class="form-label">主机地址</label>
<input name="host" class="form-control" required placeholder="1.2.3.4 或 socks.example.com"
value="{{ p.host if p else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">端口</label>
<input name="port" class="form-control" type="number" min="1" max="65535" required
value="{{ p.port if p else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">用户名(可选)</label>
<input name="username" class="form-control" value="{{ p.username if p and p.username else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">密码(可选)</label>
<input name="password" class="form-control" value="{{ p.password if p and p.password else '' }}">
</div>
<div class="col-12">
<label class="form-label">备注</label>
<textarea name="notes" class="form-control" rows="2">{{ p.notes if p else '' }}</textarea>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> 保存</button>
<a href="/proxies" class="btn btn-outline-secondary">取消</a>
</div>
</form>
</div>
</div>
{% endblock %}