feat: add model filter system with site-level batch disable, global brand filter, route status filter, and channel site-block shortcut

- Route status filter: add enabled/disabled filter chips to RouteFilterBar
- Channel site-block: add "站点屏蔽" button on channel rows to quickly
  add a model to a site's disabled list and trigger rebuild
- Site batch selection: new GET /api/sites/:id/available-models endpoint;
  Sites.tsx now shows a brand-grouped checkbox list for batch model disable
- Global brand filter: new brandMatcher.ts service; settings API and UI
  for blocking entire brands from route generation
- Fix infinite scroll race condition when sentinel ref is null during
  IntersectionObserver effect setup after filter changes
This commit is contained in:
Hureru
2026-03-21 17:55:25 +08:00
parent 3cc6b7be45
commit cf6ed7db91
20 changed files with 621 additions and 49 deletions
+32
View File
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getAllBrandNames } from '../../services/brandMatcher.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import {
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
globalBlockedBrands?: string[];
}
interface DatabaseMigrationBody {
@@ -395,6 +397,17 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
}
return;
}
case 'global_blocked_brands': {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (Array.isArray(parsed)) {
config.globalBlockedBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
}
} catch {
return;
}
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
@@ -562,6 +575,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken),
globalBlockedBrands: config.globalBlockedBrands,
};
}
@@ -639,6 +653,10 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp);
});
app.get('/api/settings/brand-list', async () => {
return { brands: getAllBrandNames() };
});
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl
@@ -946,6 +964,20 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
}
if (body.globalBlockedBrands !== undefined) {
const nextBrands = Array.isArray(body.globalBlockedBrands)
? body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean)
: [];
const uniqueBrands = Array.from(new Set(nextBrands));
const prev = JSON.stringify(config.globalBlockedBrands);
const next = JSON.stringify(uniqueBrands);
if (prev !== next) {
changedLabels.push('全局品牌屏蔽');
}
config.globalBlockedBrands = uniqueBrands;
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');
+44
View File
@@ -497,6 +497,50 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models: uniqueModels };
});
// Get all discovered models for a site (from model_availability and token_model_availability)
app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
// Get models from model_availability (account-level)
const accountModels = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.where(
and(
eq(schema.accounts.siteId, id),
eq(schema.modelAvailability.available, true),
),
)
.all();
// Get models from token_model_availability (token-level)
const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName })
.from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.where(
and(
eq(schema.accounts.siteId, id),
eq(schema.tokenModelAvailability.available, true),
),
)
.all();
const models = Array.from(new Set([
...accountModels.map((r) => r.modelName),
...tokenModels.map((r) => r.modelName),
])).filter((m) => m.trim().length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
return { siteId: id, models };
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);