Files
ToNav/templates/admin/services.html
OpenClaw Agent c0cdd146b1 feat: upgrade to V1.2 - Tags, Click Stats, and Robust WebDAV
- add Tagging system (backend and frontend)
- add Click count statistics and redirection logic
- add config.example.py
- fix WebDAV MKCOL 405 error and response handling
- fix redirection loop during force password change
- audit SQL queries for security
2026-02-13 07:58:11 +08:00

702 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}服务管理 - ToNav{% endblock %}
{% block content %}
<div class="container admin-layout">
<!-- 顶部导航 -->
<div class="admin-header">
<div class="header-left">
<h1>📡 服务管理</h1>
<a href="/admin" class="back-link">← 返回首页</a>
</div>
<div class="header-right">
<button class="btn btn-outline" onclick="window.open('/', '_blank')" style="margin-right: 10px;">查看前台 ↗</button>
<button class="btn btn-primary" onclick="showCreateModal()">+ 新建服务</button>
</div>
</div>
<!-- 服务列表 -->
<div class="services-list">
<div class="loading">加载中...</div>
</div>
<!-- 创建/编辑弹窗 -->
<div class="modal" id="serviceModal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">新建服务</h2>
<button class="close-btn" onclick="closeModal()">×</button>
</div>
<form id="serviceForm" class="modal-body">
<input type="hidden" id="serviceId">
<div class="form-group">
<label>服务名称 *</label>
<input type="text" id="serviceName" class="input" required>
</div>
<div class="form-group">
<label>访问URL *</label>
<input type="url" id="serviceUrl" class="input" required placeholder="http://">
</div>
<div class="form-group">
<label>描述</label>
<input type="text" id="serviceDesc" class="input" placeholder="简短描述">
</div>
<div class="form-group">
<label>标签 (英文逗号分隔)</label>
<input type="text" id="serviceTags" class="input" placeholder="工具, 开发, 常用">
</div>
<div class="form-row">
<div class="form-group">
<label>图标emoji</label>
<input type="text" id="serviceIcon" class="input" placeholder="📡">
</div>
<div class="form-group">
<label>分类</label>
<select id="serviceCategory" class="input">
<option value="默认">默认</option>
<option value="内网服务">内网服务</option>
<option value="开发工具">开发工具</option>
<option value="测试环境">测试环境</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>排序权重</label>
<input type="number" id="serviceSort" class="input" value="0">
</div>
<div class="form-group">
<label>状态</label>
<label class="checkbox-label">
<input type="checkbox" id="serviceEnabled" checked>
<span>启用</span>
</label>
</div>
</div>
<div class="form-divider">健康检测设置</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="healthCheckEnabled">
<span>启用健康检测</span>
</label>
</div>
<div class="form-group" id="healthUrlGroup" style="display: none;">
<label>检测URL</label>
<input type="url" id="healthCheckUrl" class="input" placeholder="留空则使用主URL">
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
<style>
:root {
--main-red: #ff4d4f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.admin-header {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
color: #fff;
padding: 25px 30px;
border-radius: 20px 20px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 22px;
font-weight: 700;
}
.back-link {
color: #8c8c8c;
font-size: 13px;
text-decoration: none;
}
.back-link:hover {
color: #bfbfbf;
}
.services-list {
background: #fff;
padding: 30px;
border-radius: 0 0 20px 20px;
min-height: 400px;
}
.service-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #fafafa;
border-radius: 12px;
margin-bottom: 15px;
transition: all 0.3s;
}
.service-item:hover {
background: #f0f0f0;
transform: translateX(5px);
}
.service-info {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.service-icon-large {
font-size: 36px;
}
.service-details {
flex: 1;
}
.service-name {
font-size: 18px;
font-weight: 600;
color: #262626;
margin-bottom: 5px;
}
.service-url {
font-size: 13px;
color: #8c8c8c;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
display: block;
}
.service-meta {
display: flex;
gap: 10px;
font-size: 12px;
color: #999;
}
.tag {
padding: 2px 8px;
border-radius: 4px;
background: #e6f7ff;
color: #1890ff;
}
.service-actions {
display: flex;
gap: 8px;
}
.action-btn-sm {
padding: 8px 14px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
}
.action-btn-sm:hover {
border-color: var(--main-red);
color: var(--main-red);
}
.action-btn-sm.danger:hover {
background: #fff2f0;
border-color: #ff4d4f;
color: #ff4d4f;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d9d9d9;
}
.status-dot.enabled {
background: #52c41a;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
}
.modal.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: #fff;
border-radius: 20px;
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
padding: 20px 25px;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 28px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close-btn:hover {
background: rgba(255,255,255,0.2);
}
.modal-body {
padding: 25px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #262626;
}
.input, .input[type="text"], .input[type="url"], .input[type="number"], select.input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s;
}
.input:focus {
outline: none;
border-color: var(--main-red);
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-divider {
margin: 25px 0;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
font-size: 14px;
font-weight: 600;
color: #8c8c8c;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.btn-outline {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
}
.btn-outline:hover {
background: rgba(255,255,255,0.1);
border-color: #fff;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: var(--main-red);
color: #fff;
box-shadow: 0 4px 15px rgba(255, 77, 79, 0.4);
}
.btn-primary:hover {
background: #ff7875;
}
.loading {
text-align: center;
padding: 60px;
color: #999;
}
.empty-state {
text-align: center;
padding: 60px;
color: #999;
}
@media (max-width: 768px) {
.service-item {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.service-actions {
width: 100%;
justify-content: flex-end;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block scripts %}
<script>
let allServices = [];
// 初始化
document.addEventListener('DOMContentLoaded', function() {
loadServices();
setupForm();
});
// 加载服务列表
async function loadServices() {
try {
// 添加时间戳防止缓存
const response = await fetch(`/api/admin/services?t=${new Date().getTime()}`);
if (!response.ok) {
window.location.href = '/admin/login';
return;
}
allServices = await response.json();
renderServices();
} catch (err) {
console.error('加载服务失败:', err);
window.location.href = '/admin/login';
}
}
// 渲染服务列表
function renderServices() {
const container = document.querySelector('.services-list');
if (allServices.length === 0) {
container.innerHTML = '<div class="empty-state">暂无服务</div>';
return;
}
let html = '';
allServices.forEach(service => {
html += `
<div class="service-item">
<div class="service-info">
<span class="service-icon-large">${service.icon || '📡'}</span>
<div class="service-details">
<div class="service-name">
${service.name}
<span class="status-dot ${service.is_enabled ? 'enabled' : ''}"></span>
</div>
<div class="service-url">${service.url}</div>
<div class="service-meta">
<span class="tag">${service.category}</span>
<span>排序: ${service.sort_order}</span>
${service.health_check_enabled ? '<span>🔍 检测中</span>' : ''}
</div>
</div>
</div>
<div class="service-actions">
<button class="action-btn-sm" onclick="editService(${service.id})">编辑</button>
<button class="action-btn-sm" onclick="toggleService(${service.id})">
${service.is_enabled ? '禁用' : '启用'}
</button>
<button class="action-btn-sm danger" onclick="deleteService(${service.id}, '${service.name}')">删除</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 设置表单
function setupForm() {
// 健康检测URL显示/隐藏
document.getElementById('healthCheckEnabled').addEventListener('change', function() {
document.getElementById('healthUrlGroup').style.display = this.checked ? 'block' : 'none';
});
// 表单提交
document.getElementById('serviceForm').addEventListener('submit', function(e) {
e.preventDefault();
saveService();
});
}
// 加载分类列表到下拉框
async function loadCategoriesToSelect() {
try {
const response = await fetch('/api/admin/categories');
const categories = await response.json();
const select = document.getElementById('serviceCategory');
const currentValue = select.value;
select.innerHTML = '';
categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat.name;
option.textContent = cat.name;
select.appendChild(option);
});
// 尝试恢复之前选中的值
if (currentValue) {
const exists = categories.find(c => c.name === currentValue);
if (exists) {
select.value = currentValue;
}
}
} catch (err) {
console.error('加载分类失败:', err);
}
}
// 显示创建弹窗
async function showCreateModal() {
document.getElementById('modalTitle').textContent = '新建服务';
document.getElementById('serviceId').value = '';
document.getElementById('serviceForm').reset();
document.getElementById('serviceEnabled').checked = true;
document.getElementById('serviceSort').value = '0';
document.getElementById('healthUrlGroup').style.display = 'none';
await loadCategoriesToSelect();
document.getElementById('serviceModal').classList.add('active');
}
// 编辑服务
async function editService(id) {
const service = allServices.find(s => s.id === id);
if (!service) return;
await loadCategoriesToSelect();
document.getElementById('modalTitle').textContent = '编辑服务';
document.getElementById('serviceId').value = service.id;
document.getElementById('serviceName').value = service.name;
document.getElementById('serviceUrl').value = service.url;
document.getElementById('serviceDesc').value = service.description || '';
document.getElementById('serviceTags').value = service.tags || '';
document.getElementById('serviceIcon').value = service.icon || '';
document.getElementById('serviceCategory').value = service.category;
document.getElementById('serviceSort').value = service.sort_order;
document.getElementById('serviceEnabled').checked = service.is_enabled === 1;
document.getElementById('healthCheckEnabled').checked = service.health_check_enabled === 1;
document.getElementById('healthCheckUrl').value = service.health_check_url || '';
document.getElementById('healthUrlGroup').style.display = service.health_check_enabled ? 'block' : 'none';
document.getElementById('serviceModal').classList.add('active');
}
// 保存服务
async function saveService() {
const id = document.getElementById('serviceId').value;
const data = {
name: document.getElementById('serviceName').value,
url: document.getElementById('serviceUrl').value,
description: document.getElementById('serviceDesc').value,
tags: document.getElementById('serviceTags').value,
icon: document.getElementById('serviceIcon').value,
category: document.getElementById('serviceCategory').value,
sort_order: parseInt(document.getElementById('serviceSort').value) || 0,
is_enabled: document.getElementById('serviceEnabled').checked ? 1 : 0,
health_check_enabled: document.getElementById('healthCheckEnabled').checked ? 1 : 0,
health_check_url: document.getElementById('healthCheckUrl').value
};
try {
const url = id ? `/api/admin/services/${id}` : '/api/admin/services';
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
closeModal();
loadServices();
} else {
const result = await response.json();
alert(result.error || '保存失败');
}
} catch (err) {
alert('请求失败: ' + err.message);
}
}
// 切换服务状态
async function toggleService(id) {
if (!confirm('确定要切换服务状态吗?')) return;
try {
const response = await fetch(`/api/admin/services/${id}/toggle`, {
method: 'POST'
});
if (response.ok) {
loadServices();
} else {
alert('操作失败');
}
} catch (err) {
alert('请求失败: ' + err.message);
}
}
// 删除服务
async function deleteService(id, name) {
if (!confirm(`确定要删除服务"${name}"吗?此操作不可恢复。`)) return;
try {
const response = await fetch(`/api/admin/services/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadServices();
} else {
alert('删除失败');
}
} catch (err) {
alert('请求失败: ' + err.message);
}
}
// 关闭弹窗
function closeModal() {
document.getElementById('serviceModal').classList.remove('active');
}
// 点击外部关闭弹窗
document.getElementById('serviceModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
</script>
{% endblock %}