feat: add /sub/clash/latest and /sub/singbox/latest public subscription APIs and display them on the web dashboard UI

This commit is contained in:
jocayn
2026-06-13 17:54:46 +08:00
parent 7d561e0c6b
commit 807b765e2c
3 changed files with 181 additions and 0 deletions
+67
View File
@@ -804,6 +804,43 @@
<i data-lucide="refresh-cw"></i>刷新历史 <i data-lucide="refresh-cw"></i>刷新历史
</button> </button>
</div> </div>
<!-- Latest Subscription Links -->
<div style="margin-bottom: 1.5rem; 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;">
<div style="font-size: 0.95rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="link" style="width: 16px; height: 16px; color: var(--accent-purple);"></i>最新订阅地址 (无Token/持久链接)
</div>
<div style="display: flex; flex-wrap: wrap; gap: 1rem;">
<div style="flex: 1; min-width: 280px; display: flex; align-items: center; justify-content: space-between; background: rgba(255, 255, 255, 0.01); border: 1px solid var(--border-color); border-radius: 8px; padding: 0.75rem 1rem;">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span style="font-size: 0.75rem; font-weight: 600; color: #a78bfa; text-transform: uppercase;">Clash (YAML)</span>
<span style="font-size: 0.85rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all;" id="latest-clash-link">加载中...</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button type="button" class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="copyLatestLink('clash')">
<i data-lucide="copy" style="width: 12px; height: 12px;"></i>复制
</button>
<a class="btn btn-primary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem; text-decoration: none; background: linear-gradient(135deg, #10B981 0%, #059669 100%);" id="latest-clash-import" href="#">
<i data-lucide="zap" style="width: 12px; height: 12px;"></i>导入 Clash
</a>
</div>
</div>
<div style="flex: 1; min-width: 280px; display: flex; align-items: center; justify-content: space-between; background: rgba(255, 255, 255, 0.01); border: 1px solid var(--border-color); border-radius: 8px; padding: 0.75rem 1rem;">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span style="font-size: 0.75rem; font-weight: 600; color: #f472b6; text-transform: uppercase;">Sing-box (JSON)</span>
<span style="font-size: 0.85rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all;" id="latest-singbox-link">加载中...</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button type="button" class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="copyLatestLink('singbox')">
<i data-lucide="copy" style="width: 12px; height: 12px;"></i>复制
</button>
<a class="btn btn-primary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem; text-decoration: none; background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);" id="latest-singbox-import" href="#">
<i data-lucide="zap" style="width: 12px; height: 12px;"></i>导入 Sing-box
</a>
</div>
</div>
</div>
</div>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
@@ -898,6 +935,7 @@
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
lucide.createIcons(); lucide.createIcons();
await loadAppConfig(); await loadAppConfig();
updateLatestUrls();
loadSubscriptions(); loadSubscriptions();
loadDnsTemplates(); loadDnsTemplates();
}); });
@@ -1512,6 +1550,35 @@
} }
} }
let latestClashUrl = '';
let latestSingboxUrl = '';
function updateLatestUrls() {
const clientOrigin = getClientImportOrigin();
latestClashUrl = `${clientOrigin}/sub/clash/latest`;
latestSingboxUrl = `${clientOrigin}/sub/singbox/latest`;
const clashLinkEl = document.getElementById('latest-clash-link');
const singboxLinkEl = document.getElementById('latest-singbox-link');
if (clashLinkEl) clashLinkEl.textContent = latestClashUrl;
if (singboxLinkEl) singboxLinkEl.textContent = latestSingboxUrl;
const clashImportEl = document.getElementById('latest-clash-import');
const singboxImportEl = document.getElementById('latest-singbox-import');
if (clashImportEl) {
clashImportEl.href = `clash://install-config?url=${encodeURIComponent(latestClashUrl)}&name=${encodeURIComponent('CatMata-Latest')}`;
}
if (singboxImportEl) {
singboxImportEl.href = `sing-box://import-remote-profile?url=${encodeURIComponent(latestSingboxUrl)}#${encodeURIComponent('CatMata-Latest')}`;
}
}
function copyLatestLink(type) {
const link = type === 'clash' ? latestClashUrl : latestSingboxUrl;
copyLinkToClipboard(link);
}
function renderHistoryList(list) { function renderHistoryList(list) {
const tbody = document.getElementById('history-list'); const tbody = document.getElementById('history-list');
tbody.innerHTML = ''; tbody.innerHTML = '';
+56
View File
@@ -262,6 +262,32 @@
<div class="muted" id="summary">正在加载...</div> <div class="muted" id="summary">正在加载...</div>
</div> </div>
</header> </header>
<div style="padding: 20px 24px; border-bottom: 1px solid var(--line); background: #fafbfc; display: flex; flex-direction: column; gap: 12px;">
<h3 style="margin: 0; font-size: 0.95rem; font-weight: 700; color: #374151;">最新订阅地址 (无Token/持久链接)</h3>
<div style="display: flex; flex-wrap: wrap; gap: 16px;">
<div style="flex: 1; min-width: 280px; display: flex; align-items: center; justify-content: space-between; background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 0.78rem; font-weight: 750; color: var(--primary); text-transform: uppercase;">Clash (YAML)</span>
<span style="font-size: 0.85rem; font-family: monospace; color: var(--muted); word-break: break-all;" id="latest-clash-link">加载中...</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" class="btn btn-secondary" onclick="copyLatestLink('clash')">复制</button>
<a class="btn btn-green" id="latest-clash-import" href="#">导入 Clash</a>
</div>
</div>
<div style="flex: 1; min-width: 280px; display: flex; align-items: center; justify-content: space-between; background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<span style="font-size: 0.78rem; font-weight: 750; color: var(--primary); text-transform: uppercase;">Sing-box (JSON)</span>
<span style="font-size: 0.85rem; font-family: monospace; color: var(--muted); word-break: break-all;" id="latest-singbox-link">加载中...</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" class="btn btn-secondary" onclick="copyLatestLink('singbox')">复制</button>
<a class="btn btn-primary" id="latest-singbox-import" href="#">导入 Sing-box</a>
</div>
</div>
</div>
</div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -339,6 +365,35 @@
} }
} }
let latestClashUrl = '';
let latestSingboxUrl = '';
function updateLatestUrls() {
const clientOrigin = getClientImportOrigin();
latestClashUrl = `${clientOrigin}/sub/clash/latest`;
latestSingboxUrl = `${clientOrigin}/sub/singbox/latest`;
const clashLinkEl = document.getElementById('latest-clash-link');
const singboxLinkEl = document.getElementById('latest-singbox-link');
if (clashLinkEl) clashLinkEl.textContent = latestClashUrl;
if (singboxLinkEl) singboxLinkEl.textContent = latestSingboxUrl;
const clashImportEl = document.getElementById('latest-clash-import');
const singboxImportEl = document.getElementById('latest-singbox-import');
if (clashImportEl) {
clashImportEl.href = `clash://install-config?url=${encodeURIComponent(latestClashUrl)}&name=${encodeURIComponent('CatMata-Latest')}`;
}
if (singboxImportEl) {
singboxImportEl.href = `sing-box://import-remote-profile?url=${encodeURIComponent(latestSingboxUrl)}#${encodeURIComponent('CatMata-Latest')}`;
}
}
function copyLatestLink(type) {
const link = type === 'clash' ? latestClashUrl : latestSingboxUrl;
copyLinkToClipboard(link);
}
async function loadAppConfig() { async function loadAppConfig() {
try { try {
const res = await fetch('/api/app-config', { credentials: 'same-origin' }); const res = await fetch('/api/app-config', { credentials: 'same-origin' });
@@ -469,6 +524,7 @@
(async function init() { (async function init() {
await loadAppConfig(); await loadAppConfig();
updateLatestUrls();
if (await checkSession()) { if (await checkSession()) {
showHistory(); showHistory();
await loadHistory(); await loadHistory();
+58
View File
@@ -707,6 +707,64 @@ function startServer() {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`); const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname; const pathname = parsedUrl.pathname;
if (pathname === '/sub/clash/latest' && req.method === 'GET') {
const filePath = path.join(outDir, 'CatMata-sub.yaml');
if (fs.existsSync(filePath)) {
res.writeHead(200, {
'Content-Type': 'text/yaml; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(filePath));
} else {
const history = getHistoryList();
if (history.length > 0 && history[0].yamlFile) {
const histPath = path.join(outDir, 'history', history[0].yamlFile);
if (fs.existsSync(histPath)) {
res.writeHead(200, {
'Content-Type': 'text/yaml; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(histPath));
return;
}
}
res.writeHead(404);
res.end('Latest Clash profile not found');
}
return;
}
if (pathname === '/sub/singbox/latest' && req.method === 'GET') {
const filePath = path.join(outDir, 'CatMata-sub.json');
if (fs.existsSync(filePath)) {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(filePath));
} else {
const history = getHistoryList();
if (history.length > 0 && history[0].jsonFile) {
const histPath = path.join(outDir, 'history', history[0].jsonFile);
if (fs.existsSync(histPath)) {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(histPath));
return;
}
}
res.writeHead(404);
res.end('Latest Sing-box profile not found');
}
return;
}
if (pathname === '/login' || pathname === '/login.html') { if (pathname === '/login' || pathname === '/login.html') {
serveHtml(res, 'login.html'); serveHtml(res, 'login.html');
return; return;