feat: Web服务卡片添加自定义链接入口功能
- 端口号/后台后添加绿色+按钮 - 点击弹出模态框输入自定义链接 - 支持端口+路径生成URL或完整URL - 链接保存到localStorage,支持删除 - 使用网页统一设置的IP生成链接
This commit is contained in:
168
app.py
168
app.py
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
项目服务管理面板 v2.1.0
|
项目服务管理面板 v2.2.0
|
||||||
端口: 19013
|
端口: 19013
|
||||||
修复: 后台链接使用 externalIp 替代 localhost
|
新增: Web服务卡片添加自定义链接入口(+按钮)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -1218,6 +1218,48 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义链接模态框 -->
|
||||||
|
<div id="linkModal" class="modal-overlay hidden">
|
||||||
|
<div class="card rounded-xl modal-content p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 id="linkModalTitle" class="font-bold text-lg">添加自定义链接</h3>
|
||||||
|
<button onclick="closeLinkModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="linkForm" onsubmit="saveCustomLink(event)">
|
||||||
|
<input type="hidden" id="linkProjectId">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">链接名称 *</label>
|
||||||
|
<input type="text" id="linkName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:API文档、管理后台">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">端口(使用统一IP)</label>
|
||||||
|
<input type="text" id="linkPort" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:19001" onchange="updateLinkPreview()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">路径(可选)</label>
|
||||||
|
<input type="text" id="linkPath" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:/admin /api/docs" onchange="updateLinkPreview()">
|
||||||
|
<p id="linkPreview" class="text-sm text-cyan-400 mt-1"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">完整URL(不填则用端口生成)</label>
|
||||||
|
<input type="text" id="linkFullUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:http://192.168.2.17:19000/docs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="existingLinks" class="mb-4"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" onclick="closeLinkModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
|
||||||
|
<button type="submit" class="btn bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg">添加</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 导航按钮 -->
|
<!-- 导航按钮 -->
|
||||||
<div class="nav-buttons">
|
<div class="nav-buttons">
|
||||||
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
|
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
|
||||||
@@ -1365,6 +1407,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|||||||
adminUrl = adminUrl.replace(/localhost/g, externalIp);
|
adminUrl = adminUrl.replace(/localhost/g, externalIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取自定义链接
|
||||||
|
const customLinks = getCustomLinks(p.id);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors">
|
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
@@ -1375,13 +1420,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|||||||
<span class="text-xs ${statusInfo.textColor}">${statusInfo.text}</span>
|
<span class="text-xs ${statusInfo.textColor}">${statusInfo.text}</span>
|
||||||
</div>
|
</div>
|
||||||
${p.ports && p.ports.length > 0 ? `
|
${p.ports && p.ports.length > 0 ? `
|
||||||
<div class="flex items-center gap-1 text-xs mb-2">
|
<div class="flex items-center gap-1 text-xs mb-2 flex-wrap">
|
||||||
${p.ports.map(port => {
|
${p.ports.map(port => {
|
||||||
const portStatus = p.status?.ports?.[port];
|
const portStatus = p.status?.ports?.[port];
|
||||||
const isRunning = portStatus?.running;
|
const isRunning = portStatus?.running;
|
||||||
return `<a href="http://${externalIp}:${port}" target="_blank" class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : 'bg-red-500/20 text-red-400'}">${port}</a>`;
|
return `<a href="http://${externalIp}:${port}" target="_blank" class="px-2 py-0.5 rounded ${isRunning ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30' : 'bg-red-500/20 text-red-400'}">${port}</a>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
${adminUrl ? `<a href="${adminUrl}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
|
${adminUrl ? `<a href="${adminUrl}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
|
||||||
|
${customLinks.map(link => `<a href="${link.url}" target="_blank" class="text-cyan-400 hover:text-cyan-300 ml-1">${link.name}</a>`).join('')}
|
||||||
|
<button onclick="showAddLinkModal('${p.id}', '${p.name}')" class="text-green-400 hover:text-green-300 ml-1" title="添加自定义链接"><i class="ri-add-line"></i></button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${p.type === 'web' ? `
|
${p.type === 'web' ? `
|
||||||
@@ -1402,6 +1449,121 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 自定义链接管理 ====================
|
||||||
|
|
||||||
|
function getCustomLinks(projectId) {
|
||||||
|
const stored = localStorage.getItem('customLinks_' + projectId);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCustomLinks(projectId, links) {
|
||||||
|
localStorage.setItem('customLinks_' + projectId, JSON.stringify(links));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddLinkModal(projectId, projectName) {
|
||||||
|
document.getElementById('linkModalTitle').textContent = `${projectName} - 添加链接`;
|
||||||
|
document.getElementById('linkProjectId').value = projectId;
|
||||||
|
document.getElementById('linkName').value = '';
|
||||||
|
document.getElementById('linkPort').value = '';
|
||||||
|
document.getElementById('linkPath').value = '';
|
||||||
|
document.getElementById('linkFullUrl').value = '';
|
||||||
|
document.getElementById('linkModal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 显示已有的自定义链接(供删除)
|
||||||
|
const existingLinks = getCustomLinks(projectId);
|
||||||
|
const existingList = document.getElementById('existingLinks');
|
||||||
|
if (existingLinks.length > 0) {
|
||||||
|
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
|
||||||
|
existingLinks.map(link => `
|
||||||
|
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
|
||||||
|
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
|
||||||
|
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
existingList.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkModal() {
|
||||||
|
document.getElementById('linkModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCustomLink(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const projectId = document.getElementById('linkProjectId').value;
|
||||||
|
const name = document.getElementById('linkName').value.trim();
|
||||||
|
const port = document.getElementById('linkPort').value.trim();
|
||||||
|
const path = document.getElementById('linkPath').value.trim();
|
||||||
|
const fullUrl = document.getElementById('linkFullUrl').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('请输入链接名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (fullUrl) {
|
||||||
|
url = fullUrl;
|
||||||
|
} else if (port) {
|
||||||
|
url = `http://${externalIp}:${port}${path || ''}`;
|
||||||
|
} else {
|
||||||
|
alert('请输入端口或完整URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = getCustomLinks(projectId);
|
||||||
|
const newLink = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: name,
|
||||||
|
url: url
|
||||||
|
};
|
||||||
|
links.push(newLink);
|
||||||
|
saveCustomLinks(projectId, links);
|
||||||
|
|
||||||
|
closeLinkModal();
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCustomLink(projectId, linkId) {
|
||||||
|
if (!confirm('确定删除此链接?')) return;
|
||||||
|
|
||||||
|
const links = getCustomLinks(projectId);
|
||||||
|
const filtered = links.filter(l => l.id !== linkId);
|
||||||
|
saveCustomLinks(projectId, filtered);
|
||||||
|
|
||||||
|
// 更新模态框中的显示
|
||||||
|
const existingLinks = getCustomLinks(projectId);
|
||||||
|
const existingList = document.getElementById('existingLinks');
|
||||||
|
if (existingLinks.length > 0) {
|
||||||
|
existingList.innerHTML = '<div class="text-gray-400 text-sm mb-2">已有链接:</div>' +
|
||||||
|
existingLinks.map(link => `
|
||||||
|
<div class="flex items-center justify-between bg-gray-700 px-2 py-1 rounded mb-1">
|
||||||
|
<span class="text-cyan-400 text-sm">${link.name}: ${link.url}</span>
|
||||||
|
<button onclick="deleteCustomLink('${projectId}', '${link.id}')" class="text-red-400 hover:text-red-300"><i class="ri-delete-bin-line"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
existingList.innerHTML = '<div class="text-gray-500 text-sm">暂无自定义链接</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动生成URL预览
|
||||||
|
function updateLinkPreview() {
|
||||||
|
const port = document.getElementById('linkPort').value.trim();
|
||||||
|
const path = document.getElementById('linkPath').value.trim();
|
||||||
|
const preview = document.getElementById('linkPreview');
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
preview.textContent = `预览: http://${externalIp}:${port}${path || ''}`;
|
||||||
|
} else {
|
||||||
|
preview.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusInfo(status) {
|
function getStatusInfo(status) {
|
||||||
const map = {
|
const map = {
|
||||||
'running': { text: '运行中', class: 'status-running', textColor: 'text-green-400' },
|
'running': { text: '运行中', class: 'status-running', textColor: 'text-green-400' },
|
||||||
|
|||||||
Reference in New Issue
Block a user