feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)

This commit is contained in:
aaaamoooo
2026-10-20 18:09:07 +00:00
parent 7c21742810
commit 011eecc096
23 changed files with 3370 additions and 146 deletions
+54 -3
View File
@@ -1,6 +1,12 @@
'use strict';
const { buildClientUri, nodeEmail } = require('./protocols');
const { dedupKey, withName, nameFromUri } = require('./extsub');
function nodeSortIndex(data, nodeId) {
const node = data.nodes.find((item) => item.id === nodeId);
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
}
// Client URLs are only issued for confirmed, running nodes. Each link is built
// on the fly from the node's current config and the customer's own credentials
@@ -22,7 +28,11 @@ function nodeClientUri(data, nodeClient) {
function entries(data, customerId) {
const seen = new Map();
const items = [];
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) {
// Own nodes follow the panel-wide custom order (sortIndex); the sort is
// stable, so equal/unset indexes keep the binding creation order.
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
for (const nodeClient of bindings) {
const uri = nodeClientUri(data, nodeClient);
if (!uri) continue;
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
@@ -38,6 +48,47 @@ function entries(data, customerId) {
return items;
}
// Full subscription item list: own nodes (sortIndex order), then manual
// links (list order), then external sources (source order, cached order).
// Global dedup by scheme+host+port+credential fingerprint, own first.
// Name collisions across segments get the " (n)" suffix treatment.
function aggregateEntries(data, customer) {
const items = entries(data, customer.id);
const usedKeys = new Set();
const usedNames = new Set();
for (const item of items) {
const key = dedupKey(item.clientUri);
if (key) usedKeys.add(key);
usedNames.add(nameFromUri(item.clientUri));
}
const append = (uri, rawName) => {
const key = dedupKey(uri);
if (key && usedKeys.has(key)) return;
let name = rawName || '未命名节点';
if (usedNames.has(name)) {
let index = 2;
while (usedNames.has(`${name} (${index})`)) index += 1;
name = `${name} (${index})`;
}
if (key) usedKeys.add(key);
usedNames.add(name);
items.push({ nodeId: '', clientUri: withName(uri, name) });
};
(customer.manualLinks || []).forEach((link, index) => {
if (!link.enabled) return;
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
});
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const cache = (data.subSourceCache || {})[source.id];
if (!cache || !Array.isArray(cache.items)) continue;
for (const cached of cache.items) {
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
}
}
return items;
}
function q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) {
@@ -149,7 +200,7 @@ function makeSingBox(items) {
}
function formatSubscription(data, customer, format, userAgent = '') {
const items = entries(data, customer.id);
const items = aggregateEntries(data, customer);
if (!items.length) return null;
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
@@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
return undefined;
}
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };