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
+1
View File
@@ -114,6 +114,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))), proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS), proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false), proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
globalBlockedBrands: [] as string[],
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06', codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
codexHeaderDefaults: { codexHeaderDefaults: {
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT), userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
+5
View File
@@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
} }
const globalBlockedBrands = parseSettingFromMap<string[]>(settingsMap, 'global_blocked_brands');
if (Array.isArray(globalBlockedBrands)) {
config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
}
const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults'); const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults');
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
const next = codexHeaderDefaults as Record<string, unknown>; const next = codexHeaderDefaults as Record<string, unknown>;
+32
View File
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js'; import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getAllBrandNames } from '../../services/brandMatcher.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js'; import { sendNotification } from '../../services/notifyService.js';
import { import {
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
routingWeights?: Partial<RoutingWeights>; routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string; proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean; proxyEmptyContentFailEnabled?: boolean;
globalBlockedBrands?: string[];
} }
interface DatabaseMigrationBody { interface DatabaseMigrationBody {
@@ -395,6 +397,17 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
} }
return; 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': { case 'webhook_url': {
if (typeof value !== 'string') return; if (typeof value !== 'string') return;
config.webhookUrl = value.trim(); config.webhookUrl = value.trim();
@@ -562,6 +575,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
proxyErrorKeywords: config.proxyErrorKeywords, proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled, proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken), proxyTokenMasked: maskSecret(config.proxyToken),
globalBlockedBrands: config.globalBlockedBrands,
}; };
} }
@@ -639,6 +653,10 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp); 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) => { app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl ? config.systemProxyUrl
@@ -946,6 +964,20 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled); 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 (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址'); changedLabels.push('Webhook 地址');
+44
View File
@@ -497,6 +497,50 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models: uniqueModels }; 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 // Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url); const result = await detectSite(request.body.url);
+69
View File
@@ -0,0 +1,69 @@
/**
* Server-side brand matching for global brand filtering.
*
* Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS.
* Each brand maps to a set of keywords that, when found in a model name, identify
* the model as belonging to that brand.
*/
const BRAND_KEYWORDS: Record<string, string[]> = {
'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'],
'Anthropic': ['claude'],
'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'],
'DeepSeek': ['deepseek', 'ds-chat'],
'通义千问': ['qwen', 'qwq', 'tongyi'],
'智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'],
'Meta': ['llama', 'code-llama', 'codellama'],
'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'],
'Moonshot': ['moonshot', 'kimi'],
'零一万物': ['yi-'],
'文心一言': ['ernie', 'eb-'],
'讯飞星火': ['spark', 'generalv'],
'腾讯混元': ['hunyuan', 'tencent-hunyuan'],
'豆包': ['doubao'],
'MiniMax': ['minimax', 'abab'],
'Cohere': ['command', 'c4ai-'],
'Microsoft': ['phi-', 'kosmos'],
'xAI': ['grok'],
'阶跃星辰': ['stepfun', 'step-'],
'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'],
'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'],
'IBM': ['granite'],
'BAAI': ['bge-'],
'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'],
'InternLM': ['internlm'],
'Midjourney': ['midjourney', 'mj_'],
'DeepL': ['deepl-', 'deepl/'],
'Jina AI': ['jina'],
};
/**
* Get all available brand names.
*/
export function getAllBrandNames(): string[] {
return Object.keys(BRAND_KEYWORDS);
}
/**
* Get the flattened list of keywords for blocked brands.
*/
export function getBlockedModelKeywords(blockedBrands: string[]): string[] {
const keywords: string[] = [];
for (const brand of blockedBrands) {
const brandKeywords = BRAND_KEYWORDS[brand];
if (brandKeywords) {
keywords.push(...brandKeywords);
}
}
return keywords;
}
/**
* Check if a model name matches any of the blocked keywords.
* Uses a case-insensitive substring match (same approach as frontend BrandIcon).
*/
export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean {
const lower = modelName.trim().toLowerCase();
if (!lower) return false;
return blockedKeywords.some((keyword) => lower.includes(keyword));
}
+6
View File
@@ -18,6 +18,8 @@ import {
supportsDirectAccountRoutingConnection, supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js'; } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js'; import { invalidateTokenRouterCache } from './tokenRouter.js';
import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js';
import { config } from '../config.js';
import { setAccountRuntimeHealth } from './accountHealthService.js'; import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
@@ -1139,11 +1141,15 @@ export async function rebuildTokenRoutesFromAvailability() {
return !!disabled && disabled.has(modelName); return !!disabled && disabled.has(modelName);
} }
// Load global brand filter
const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands);
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>(); const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => { const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim(); const modelName = (modelNameRaw || '').trim();
if (!modelName) return; if (!modelName) return;
if (isModelDisabledForSite(siteId, modelName)) return; if (isModelDisabledForSite(siteId, modelName)) return;
if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map()); if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
const candidateKey = `${accountId}:${tokenId ?? 'account'}`; const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
+2
View File
@@ -421,6 +421,7 @@ export const api = {
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }), detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`), getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`),
updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }), updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }),
getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`),
// Accounts // Accounts
getAccounts: () => request('/api/accounts'), getAccounts: () => request('/api/accounts'),
@@ -570,6 +571,7 @@ export const api = {
method: 'POST', body: JSON.stringify({ oldToken, newToken }), method: 'POST', body: JSON.stringify({ oldToken, newToken }),
}), }),
getRuntimeSettings: () => request('/api/settings/runtime'), getRuntimeSettings: () => request('/api/settings/runtime'),
getBrandList: () => request('/api/settings/brand-list'),
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', { updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
+75 -1
View File
@@ -54,6 +54,7 @@ type RuntimeSettings = {
proxyTokenMasked?: string; proxyTokenMasked?: string;
adminIpAllowlist?: string[]; adminIpAllowlist?: string[];
currentAdminIp?: string; currentAdminIp?: string;
globalBlockedBrands?: string[];
}; };
type SystemProxyTestState = type SystemProxyTestState =
@@ -234,6 +235,9 @@ export default function Settings() {
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false); const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [allBrandNames, setAllBrandNames] = useState<string[]>([]);
const [blockedBrands, setBlockedBrands] = useState<string[]>([]);
const [savingBrandFilter, setSavingBrandFilter] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false);
const [adminIpAllowlistText, setAdminIpAllowlistText] = useState(''); const [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
const [clearingCache, setClearingCache] = useState(false); const [clearingCache, setClearingCache] = useState(false);
@@ -413,13 +417,15 @@ export default function Settings() {
const loadSettings = async () => { const loadSettings = async () => {
setLoading(true); setLoading(true);
try { try {
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([ const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([
api.getAuthInfo(), api.getAuthInfo(),
api.getRuntimeSettings(), api.getRuntimeSettings(),
api.getDownstreamApiKeys(), api.getDownstreamApiKeys(),
api.getRoutesLite(), api.getRoutesLite(),
api.getRuntimeDatabaseConfig(), api.getRuntimeDatabaseConfig(),
api.getBrandList(),
]); ]);
setAllBrandNames(Array.isArray(brandListRes?.brands) ? brandListRes.brands : []);
setMaskedToken(authInfo.masked || '****'); setMaskedToken(authInfo.masked || '****');
setRuntime({ setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *', checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
@@ -451,7 +457,9 @@ export default function Settings() {
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [], : [],
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
}); });
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
setProxyErrorKeywordsText( setProxyErrorKeywordsText(
Array.isArray(runtimeInfo.proxyErrorKeywords) Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n') ? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
@@ -816,6 +824,28 @@ export default function Settings() {
})); }));
}; };
const handleSaveBrandFilter = async () => {
setSavingBrandFilter(true);
try {
const res = await api.updateRuntimeSettings({ globalBlockedBrands: blockedBrands });
setRuntime((prev) => ({
...prev,
globalBlockedBrands: Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands,
}));
toast.success('品牌屏蔽设置已保存');
try {
await api.rebuildRoutes(false);
toast.success('路由已重建');
} catch {
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
}
} catch (err: any) {
toast.error(err?.message || '保存品牌屏蔽设置失败');
} finally {
setSavingBrandFilter(false);
}
};
const saveSecuritySettings = async () => { const saveSecuritySettings = async () => {
setSavingSecurity(true); setSavingSecurity(true);
try { try {
@@ -1426,7 +1456,51 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* Global Brand Filter */}
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局品牌屏蔽</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{allBrandNames.map((brand) => {
const isBlocked = blockedBrands.includes(brand);
return (
<button
key={brand}
type="button"
onClick={() => {
if (isBlocked) {
setBlockedBrands((prev) => prev.filter((b) => b !== brand));
} else {
setBlockedBrands((prev) => [...prev, brand]);
}
}}
className={`badge ${isBlocked ? 'badge-warning' : 'badge-muted'}`}
style={{
fontSize: 12, cursor: 'pointer', border: 'none', padding: '5px 12px',
transition: 'all 0.15s ease',
}}
>
{brand}
</button>
);
})}
{allBrandNames.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载品牌列表中...</span>
)}
</div>
{blockedBrands.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
</div>
)}
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存品牌屏蔽'}
</button>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。 可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
+147 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js';
@@ -222,6 +223,8 @@ export default function Sites() {
const [disabledModelInput, setDisabledModelInput] = useState(''); const [disabledModelInput, setDisabledModelInput] = useState('');
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [disabledModelSearch, setDisabledModelSearch] = useState('');
if (editor) lastEditorRef.current = editor; if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current; const activeEditor = editor || lastEditorRef.current;
@@ -326,12 +329,20 @@ export default function Sites() {
setEditor({ mode: 'edit', editingSiteId: site.id }); setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site)); setForm(siteFormFromSite(site));
scrollToEditorTop(); scrollToEditorTop();
// Load disabled models for this site // Load disabled models and available models for this site
setDisabledModelsLoading(true); setDisabledModelsLoading(true);
setDisabledModels([]); setDisabledModels([]);
setDisabledModelInput(''); setDisabledModelInput('');
api.getSiteDisabledModels(site.id) setAvailableModels([]);
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) setDisabledModelSearch('');
Promise.all([
api.getSiteDisabledModels(site.id),
api.getSiteAvailableModels(site.id),
])
.then(([disabledRes, availableRes]: any[]) => {
setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []);
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
})
.catch(() => { }) .catch(() => { })
.finally(() => setDisabledModelsLoading(false)); .finally(() => setDisabledModelsLoading(false));
}; };
@@ -913,7 +924,7 @@ export default function Sites() {
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}> <div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div> <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
</div> </div>
{disabledModelsLoading ? ( {disabledModelsLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
@@ -921,11 +932,127 @@ export default function Sites() {
</div> </div>
) : ( ) : (
<> <>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}> {/* Search and brand group controls */}
{disabledModels.length === 0 && ( {availableModels.length > 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span> <div style={{ marginBottom: 10 }}>
<input
placeholder="搜索模型名称..."
value={disabledModelSearch}
onChange={(e) => setDisabledModelSearch(e.target.value)}
style={{
width: '100%', padding: '6px 10px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 8,
}}
/>
{/* Brand group quick actions */}
{(() => {
const brandGroups = new Map<string, string[]>();
for (const model of availableModels) {
const brand = getBrand(model);
const brandName = brand?.name || '其他';
if (!brandGroups.has(brandName)) brandGroups.set(brandName, []);
brandGroups.get(brandName)!.push(model);
}
const sortedBrands = [...brandGroups.entries()].sort((a, b) => {
if (a[0] === '其他') return 1;
if (b[0] === '其他') return -1;
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
});
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: '24px' }}>按品牌全选:</span>
{sortedBrands.map(([brandName, models]) => {
const allDisabled = models.every((m) => disabledModels.includes(m));
return (
<button
key={brandName}
type="button"
onClick={() => {
if (allDisabled) {
setDisabledModels((prev) => prev.filter((m) => !models.includes(m)));
} else {
setDisabledModels((prev) => Array.from(new Set([...prev, ...models])));
}
}}
className={`badge ${allDisabled ? 'badge-warning' : 'badge-muted'}`}
style={{ fontSize: 10, cursor: 'pointer', border: 'none', padding: '3px 8px' }}
data-tooltip={allDisabled ? `取消禁用全部 ${brandName} 模型 (${models.length})` : `禁用全部 ${brandName} 模型 (${models.length})`}
>
{brandName} ({models.length})
</button>
);
})}
</div>
);
})()}
{/* Checkbox list */}
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', padding: '4px 0' }}>
{(() => {
const q = disabledModelSearch.trim().toLowerCase();
const filtered = q ? availableModels.filter((m) => m.toLowerCase().includes(q)) : availableModels;
if (filtered.length === 0) {
return <div style={{ padding: '8px 12px', fontSize: 12, color: 'var(--color-text-muted)' }}>无匹配模型</div>;
}
// Group by brand
const brandGroups = new Map<string, string[]>();
for (const model of filtered) {
const brand = getBrand(model);
const brandName = brand?.name || '其他';
if (!brandGroups.has(brandName)) brandGroups.set(brandName, []);
brandGroups.get(brandName)!.push(model);
}
const sortedBrands = [...brandGroups.entries()].sort((a, b) => {
if (a[0] === '其他') return 1;
if (b[0] === '其他') return -1;
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
});
return sortedBrands.map(([brandName, models]) => (
<div key={brandName}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-secondary)', padding: '4px 12px', background: 'var(--color-bg)', borderBottom: '1px solid var(--color-border-light)' }}>
{brandName} ({models.length})
</div>
{models.map((model) => (
<label
key={model}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '3px 12px',
fontSize: 12, cursor: 'pointer', lineHeight: 1.6,
background: disabledModels.includes(model) ? 'color-mix(in srgb, var(--color-warning) 8%, transparent)' : 'transparent',
}}
>
<input
type="checkbox"
checked={disabledModels.includes(model)}
onChange={(e) => {
if (e.target.checked) {
setDisabledModels((prev) => [...prev, model]);
} else {
setDisabledModels((prev) => prev.filter((m) => m !== model));
}
}}
/>
<span style={{ color: disabledModels.includes(model) ? 'var(--color-warning)' : 'var(--color-text-primary)' }}>
{model}
</span>
</label>
))}
</div>
));
})()}
</div>
</div>
)} )}
{disabledModels.map((model) => (
{/* Manually disabled models not in available list */}
{(() => {
const manualOnly = disabledModels.filter((m) => !availableModels.includes(m));
if (manualOnly.length === 0) return null;
return (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>手动添加的禁用模型(不在站点已发现列表中):</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{manualOnly.map((model) => (
<span <span
key={model} key={model}
className="badge badge-muted" className="badge badge-muted"
@@ -946,9 +1073,14 @@ export default function Sites() {
</span> </span>
))} ))}
</div> </div>
</div>
);
})()}
{/* Manual input for models not in the list */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input <input
placeholder="输入模型名称,如 gpt-4o" placeholder="手动输入模型名称,如 gpt-4o"
value={disabledModelInput} value={disabledModelInput}
onChange={(e) => setDisabledModelInput(e.target.value)} onChange={(e) => setDisabledModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }}
@@ -962,14 +1094,19 @@ export default function Sites() {
添加 添加
</button> </button>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
<button <button
onClick={handleSaveDisabledModels} onClick={handleSaveDisabledModels}
disabled={disabledModelsSaving} disabled={disabledModelsSaving}
className="btn btn-primary" className="btn btn-primary"
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }} style={{ fontSize: 12, padding: '6px 16px' }}
> >
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'} {disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
</button> </button>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
已禁用 {disabledModels.length} 个模型
</span>
</div>
</> </>
)} )}
</div> </div>
+124 -5
View File
@@ -54,7 +54,7 @@ import {
getModelPatternError, getModelPatternError,
} from './token-routes/utils.js'; } from './token-routes/utils.js';
import { useRouteChannels } from './token-routes/useRouteChannels.js'; import { useRouteChannels } from './token-routes/useRouteChannels.js';
import RouteFilterBar from './token-routes/RouteFilterBar.js'; import RouteFilterBar, { type EnabledFilter } from './token-routes/RouteFilterBar.js';
import ManualRoutePanel from './token-routes/ManualRoutePanel.js'; import ManualRoutePanel from './token-routes/ManualRoutePanel.js';
import RouteCard from './token-routes/RouteCard.js'; import RouteCard from './token-routes/RouteCard.js';
import AddChannelModal from './token-routes/AddChannelModal.js'; import AddChannelModal from './token-routes/AddChannelModal.js';
@@ -119,6 +119,7 @@ export default function TokenRoutes() {
const [activeSite, setActiveSite] = useState<string | null>(null); const [activeSite, setActiveSite] = useState<string | null>(null);
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null); const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null); const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all');
const [filterCollapsed, setFilterCollapsed] = useState(true); const [filterCollapsed, setFilterCollapsed] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
@@ -640,6 +641,16 @@ export default function TokenRoutes() {
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null; return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
}, [activeGroupFilter, listVisibleRoutes]); }, [activeGroupFilter, listVisibleRoutes]);
const enabledCounts = useMemo(() => {
let enabled = 0;
let disabled = 0;
for (const route of listVisibleRoutes) {
if (route.enabled) enabled++;
else disabled++;
}
return { enabled, disabled };
}, [listVisibleRoutes]);
const sortedRoutes = useMemo(() => ( const sortedRoutes = useMemo(() => (
[...listVisibleRoutes].sort((a, b) => { [...listVisibleRoutes].sort((a, b) => {
if (sortBy === 'channelCount') { if (sortBy === 'channelCount') {
@@ -655,6 +666,12 @@ export default function TokenRoutes() {
const filteredRoutes = useMemo(() => { const filteredRoutes = useMemo(() => {
let list = sortedRoutes; let list = sortedRoutes;
if (enabledFilter === 'enabled') {
list = list.filter((route) => route.enabled);
} else if (enabledFilter === 'disabled') {
list = list.filter((route) => !route.enabled);
}
if (activeGroupFilter === '__all__') { if (activeGroupFilter === '__all__') {
list = list.filter((route) => !isRouteExactModel(route)); list = list.filter((route) => !isRouteExactModel(route));
} else if (typeof activeGroupFilter === 'number') { } else if (typeof activeGroupFilter === 'number') {
@@ -694,6 +711,7 @@ export default function TokenRoutes() {
return list; return list;
}, [ }, [
sortedRoutes, sortedRoutes,
enabledFilter,
activeGroupFilter, activeGroupFilter,
activeBrand, activeBrand,
activeSite, activeSite,
@@ -713,6 +731,8 @@ export default function TokenRoutes() {
const loadMoreSentinelRef = useRef<HTMLDivElement>(null); const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length;
useEffect(() => { useEffect(() => {
const el = loadMoreSentinelRef.current; const el = loadMoreSentinelRef.current;
if (!el) return; if (!el) return;
@@ -722,7 +742,7 @@ export default function TokenRoutes() {
); );
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [handleLoadMoreRoutes]); }, [handleLoadMoreRoutes, shouldShowLoadMore]);
const visibleRoutes = useMemo( const visibleRoutes = useMemo(
() => filteredRoutes.slice(0, visibleRouteCount), () => filteredRoutes.slice(0, visibleRouteCount),
@@ -763,12 +783,45 @@ export default function TokenRoutes() {
}; };
const handleDeleteChannel = async (channelId: number, routeId: number) => { const handleDeleteChannel = async (channelId: number, routeId: number) => {
const dismissedKey = 'metapi:channel-delete-warning-dismissed';
const dismissed = localStorage.getItem(dismissedKey) === 'true';
if (!dismissed) {
const dontAskAgain = { checked: false };
const confirmed = await new Promise<boolean>((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
const dialog = document.createElement('div');
dialog.style.cssText = 'background:var(--color-bg-card,#fff);border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2)';
dialog.innerHTML = `
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认移除通道</div>
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
移除的通道会在定时模型刷新时被自动重建恢复。<br/>如果只是想临时停用通道,建议使用<b>禁用开关</b>。
</div>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--color-text-muted);margin-bottom:16px;cursor:pointer">
<input type="checkbox" id="__ch_del_dismiss" /> 以后不再提示
</label>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button id="__ch_del_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
<button id="__ch_del_confirm" class="btn btn-danger" style="padding:6px 16px">确认移除</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('#__ch_del_cancel')!.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); });
dialog.querySelector('#__ch_del_confirm')!.addEventListener('click', () => {
dontAskAgain.checked = (dialog.querySelector('#__ch_del_dismiss') as HTMLInputElement).checked;
document.body.removeChild(overlay);
resolve(true);
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } });
});
if (!confirmed) return;
if (dontAskAgain.checked) localStorage.setItem(dismissedKey, 'true');
}
try { try {
await api.deleteChannel(channelId); await api.deleteChannel(channelId);
toast.success('通道已移除'); toast.success('通道已移除');
// Reload channels for this route
await loadChannels(routeId, true); await loadChannels(routeId, true);
// Update channel count in summary
setRouteSummaries((prev) => setRouteSummaries((prev) =>
prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r), prev.map((r) => r.id === routeId ? { ...r, channelCount: Math.max(0, r.channelCount - 1) } : r),
); );
@@ -777,6 +830,16 @@ export default function TokenRoutes() {
} }
}; };
const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => {
try {
await api.updateChannel(channelId, { enabled });
toast.success(enabled ? '通道已启用' : '通道已禁用');
await loadChannels(routeId, true);
} catch (e: any) {
toast.error(e.message || '更新通道状态失败');
}
};
const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => { const handleChannelTokenSave = async (routeId: number, channelId: number, accountId: number) => {
const tokenId = channelTokenDraft[channelId]; const tokenId = channelTokenDraft[channelId];
const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || []; const tokenOptions = getRouteCandidateView(routeId).tokenOptionsByAccountId[accountId] || [];
@@ -847,6 +910,42 @@ export default function TokenRoutes() {
} }
}; };
const handleSiteBlockModel = async (channelId: number, routeId: number) => {
const channels = channelsByRouteId[routeId] || [];
const channel = channels.find((c) => c.id === channelId);
if (!channel?.site?.id) {
toast.error('找不到通道对应的站点信息');
return;
}
const route = routeSummaries.find((r) => r.id === routeId);
const modelName = channel.sourceModel || route?.modelPattern || '';
if (!modelName) {
toast.error('找不到要屏蔽的模型名');
return;
}
const siteName = channel.site.name || '未知站点';
const confirmed = window.confirm(
`确定将模型「${modelName}」加入站点「${siteName}」的禁用列表吗?\n\n执行后将自动触发路由重建,该站点下此模型的通道将不再生成。`,
);
if (!confirmed) return;
try {
const siteId = channel.site.id;
const existing = await api.getSiteDisabledModels(siteId);
const currentModels: string[] = existing?.models || [];
if (currentModels.includes(modelName)) {
toast.info(`模型「${modelName}」已在站点「${siteName}」的禁用列表中`);
return;
}
await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]);
toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`);
await api.rebuildRoutes(false);
await load();
} catch (e: any) {
toast.error(e.message || '站点屏蔽模型失败');
}
};
const toggleExpand = async (routeId: number) => { const toggleExpand = async (routeId: number) => {
const isCurrentlyExpanded = expandedRouteIds.includes(routeId); const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) { if (isCurrentlyExpanded) {
@@ -985,6 +1084,12 @@ export default function TokenRoutes() {
(channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId), (channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId),
[], [],
); );
const handleToggleChannelEnabledRef = useRef(handleToggleChannelEnabled);
handleToggleChannelEnabledRef.current = handleToggleChannelEnabled;
const stableToggleChannelEnabled = useCallback(
(channelId: number, routeId: number, enabled: boolean) => handleToggleChannelEnabledRef.current(channelId, routeId, enabled),
[],
);
const handleChannelDragEndRef = useRef(handleChannelDragEnd); const handleChannelDragEndRef = useRef(handleChannelDragEnd);
handleChannelDragEndRef.current = handleChannelDragEnd; handleChannelDragEndRef.current = handleChannelDragEnd;
const stableChannelDragEnd = useCallback( const stableChannelDragEnd = useCallback(
@@ -997,6 +1102,12 @@ export default function TokenRoutes() {
(accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName), (accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName),
[], [],
); );
const handleSiteBlockModelRef = useRef(handleSiteBlockModel);
handleSiteBlockModelRef.current = handleSiteBlockModel;
const stableSiteBlockModel = useCallback(
(channelId: number, routeId: number) => handleSiteBlockModelRef.current(channelId, routeId),
[],
);
const addChannelModalRoute = addChannelModalRouteId const addChannelModalRoute = addChannelModalRouteId
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null ? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
@@ -1133,6 +1244,9 @@ export default function TokenRoutes() {
setActiveEndpointType={setActiveEndpointType} setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter} setActiveGroupFilter={setActiveGroupFilter}
enabledFilter={enabledFilter}
setEnabledFilter={setEnabledFilter}
enabledCounts={enabledCounts}
brandList={brandList} brandList={brandList}
siteList={siteList} siteList={siteList}
endpointTypeList={endpointTypeList} endpointTypeList={endpointTypeList}
@@ -1153,6 +1267,9 @@ export default function TokenRoutes() {
setActiveEndpointType={setActiveEndpointType} setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter} setActiveGroupFilter={setActiveGroupFilter}
enabledFilter={enabledFilter}
setEnabledFilter={setEnabledFilter}
enabledCounts={enabledCounts}
brandList={brandList} brandList={brandList}
siteList={siteList} siteList={siteList}
endpointTypeList={endpointTypeList} endpointTypeList={endpointTypeList}
@@ -1311,11 +1428,13 @@ export default function TokenRoutes() {
onTokenDraftChange={stableTokenDraftChange} onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave} onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel} onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd} onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
onCreateTokenForMissing={stableCreateTokenForMissing} onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel} onAddChannel={stableAddChannel}
onSiteBlockModel={stableSiteBlockModel}
expandedSourceGroupMap={expandedSourceGroupMap} expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup} onToggleSourceGroup={stableToggleSourceGroup}
/> />
@@ -1323,7 +1442,7 @@ export default function TokenRoutes() {
})} })}
</div> </div>
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( {shouldShowLoadMore && (
<div <div
ref={loadMoreSentinelRef} ref={loadMoreSentinelRef}
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }} style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
factoryReset: vi.fn(), factoryReset: vi.fn(),
}, },
})); }));
@@ -77,6 +78,7 @@ describe('Settings factory reset', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
triggerCheckinAll: vi.fn(), triggerCheckinAll: vi.fn(),
}, },
@@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
getDownstreamApiKeys: vi.fn(), getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
testSystemProxy: vi.fn(), testSystemProxy: vi.fn(),
}, },
@@ -59,6 +60,7 @@ describe('Settings system proxy', () => {
}); });
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]); apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({ apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false }, active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null, saved: null,
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
getSites: vi.fn(), getSites: vi.fn(),
addSite: vi.fn(), addSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
}, },
})); }));
@@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getSites: vi.fn(), getSites: vi.fn(),
getSiteDisabledModels: vi.fn(), getSiteDisabledModels: vi.fn(),
getSiteAvailableModels: vi.fn(),
updateSiteDisabledModels: vi.fn(), updateSiteDisabledModels: vi.fn(),
rebuildRoutes: vi.fn(), rebuildRoutes: vi.fn(),
}, },
@@ -57,6 +58,7 @@ describe('Sites disabled models save', () => {
}, },
]); ]);
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] }); apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] });
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true }); apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
}); });
+1
View File
@@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getSites: vi.fn(), getSites: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }), getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
}, },
})); }));
+8
View File
@@ -61,6 +61,7 @@ type RouteCardProps = {
onTokenDraftChange: (channelId: number, tokenId: number) => void; onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: (routeId: number, channelId: number, accountId: number) => void; onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
onDeleteChannel: (channelId: number, routeId: number) => void; onDeleteChannel: (channelId: number, routeId: number) => void;
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void; onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
// Missing token hints // Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
@@ -68,6 +69,8 @@ type RouteCardProps = {
onCreateTokenForMissing: (accountId: number, modelName: string) => void; onCreateTokenForMissing: (accountId: number, modelName: string) => void;
// Add channel // Add channel
onAddChannel: (routeId: number) => void; onAddChannel: (routeId: number) => void;
// Site block model
onSiteBlockModel: (channelId: number, routeId: number) => void;
// Source group expansion // Source group expansion
expandedSourceGroupMap: Record<string, boolean>; expandedSourceGroupMap: Record<string, boolean>;
onToggleSourceGroup: (groupKey: string) => void; onToggleSourceGroup: (groupKey: string) => void;
@@ -119,11 +122,13 @@ function RouteCardInner({
onTokenDraftChange, onTokenDraftChange,
onSaveToken, onSaveToken,
onDeleteChannel, onDeleteChannel,
onToggleChannelEnabled,
onChannelDragEnd, onChannelDragEnd,
missingTokenSiteItems, missingTokenSiteItems,
missingTokenGroupItems, missingTokenGroupItems,
onCreateTokenForMissing, onCreateTokenForMissing,
onAddChannel, onAddChannel,
onSiteBlockModel,
expandedSourceGroupMap, expandedSourceGroupMap,
onToggleSourceGroup, onToggleSourceGroup,
}: RouteCardProps) { }: RouteCardProps) {
@@ -548,6 +553,7 @@ function RouteCardInner({
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
/> />
); );
})} })}
@@ -572,6 +578,8 @@ function RouteCardInner({
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
/> />
); );
})} })}
+38 -1
View File
@@ -5,6 +5,8 @@ import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js'; import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
type RouteFilterBarProps = { type RouteFilterBarProps = {
totalRouteCount: number; totalRouteCount: number;
activeBrand: string | null; activeBrand: string | null;
@@ -15,6 +17,9 @@ type RouteFilterBarProps = {
setActiveEndpointType: (endpointType: string | null) => void; setActiveEndpointType: (endpointType: string | null) => void;
activeGroupFilter: GroupFilter; activeGroupFilter: GroupFilter;
setActiveGroupFilter: (filter: GroupFilter) => void; setActiveGroupFilter: (filter: GroupFilter) => void;
enabledFilter: EnabledFilter;
setEnabledFilter: (filter: EnabledFilter) => void;
enabledCounts: { enabled: number; disabled: number };
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number }; brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
siteList: [string, { count: number; siteId: number }][]; siteList: [string, { count: number; siteId: number }][];
endpointTypeList: [string, number][]; endpointTypeList: [string, number][];
@@ -63,8 +68,11 @@ function ActiveFilterSummary({
activeSite, activeSite,
activeGroupFilter, activeGroupFilter,
activeEndpointType, activeEndpointType,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType'>) { enabledFilter,
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType' | 'enabledFilter'>) {
const tags: string[] = []; const tags: string[] = [];
if (enabledFilter === 'enabled') tags.push('状态=启用');
else if (enabledFilter === 'disabled') tags.push('状态=禁用');
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`); if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
if (activeSite) tags.push(`站点=${activeSite}`); if (activeSite) tags.push(`站点=${activeSite}`);
if (activeGroupFilter === '__all__') tags.push('群组=全部'); if (activeGroupFilter === '__all__') tags.push('群组=全部');
@@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
setActiveEndpointType, setActiveEndpointType,
activeGroupFilter, activeGroupFilter,
setActiveGroupFilter, setActiveGroupFilter,
enabledFilter,
setEnabledFilter,
enabledCounts,
brandList, brandList,
siteList, siteList,
endpointTypeList, endpointTypeList,
@@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
activeSite={activeSite} activeSite={activeSite}
activeGroupFilter={activeGroupFilter} activeGroupFilter={activeGroupFilter}
activeEndpointType={activeEndpointType} activeEndpointType={activeEndpointType}
enabledFilter={enabledFilter}
/> />
</button> </button>
{/* Expanded panel */} {/* Expanded panel */}
{expandPresence.shouldRender && ( {expandPresence.shouldRender && (
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}> <div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
active={enabledFilter === 'all'}
label={tr('全部')}
count={totalRouteCount}
icon={<span style={{ fontSize: 10 }}>✦</span>}
onClick={() => setEnabledFilter('all')}
/>
<FilterChip
active={enabledFilter === 'enabled'}
label={tr('仅启用')}
count={enabledCounts.enabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-success)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'enabled' ? 'all' : 'enabled')}
/>
<FilterChip
active={enabledFilter === 'disabled'}
label={tr('仅禁用')}
count={enabledCounts.disabled}
icon={<span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>●</span>}
onClick={() => setEnabledFilter(enabledFilter === 'disabled' ? 'all' : 'disabled')}
/>
</FilterRow>
{/* Brand row */} {/* Brand row */}
<FilterRow label={tr('品牌')}> <FilterRow label={tr('品牌')}>
<FilterChip <FilterChip
@@ -25,6 +25,8 @@ export function SortableChannelRow({
onTokenDraftChange, onTokenDraftChange,
onSaveToken, onSaveToken,
onDeleteChannel, onDeleteChannel,
onToggleEnabled,
onSiteBlockModel,
}: SortableChannelRowProps) { }: SortableChannelRowProps) {
const { const {
attributes, attributes,
@@ -42,7 +44,7 @@ export function SortableChannelRow({
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.72 : 1, opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : 1, zIndex: isDragging ? 10 : 1,
display: 'grid', display: 'grid',
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto', gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
@@ -376,6 +378,10 @@ export function SortableChannelRow({
</span> </span>
) : null} ) : null}
{channel.enabled === false ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span> <span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
@@ -501,12 +507,32 @@ export function SortableChannelRow({
</button> </button>
</div> </div>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={channel.enabled === false ? '启用此通道' : '禁用此通道'}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={`将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
站点屏蔽
</button>
) : null}
<button <button
onClick={onDeleteChannel} onClick={onDeleteChannel}
className="btn btn-link btn-link-danger" className="btn btn-link btn-link-danger"
> >
移除 移除
</button> </button>
</div>
</> </>
) )
) : null} ) : null}
+2
View File
@@ -161,6 +161,8 @@ export type SortableChannelRowProps = {
onTokenDraftChange: (channelId: number, tokenId: number) => void; onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: () => void; onSaveToken: () => void;
onDeleteChannel: () => void; onDeleteChannel: () => void;
onToggleEnabled: (enabled: boolean) => void;
onSiteBlockModel?: () => void;
}; };
export type GroupRouteItem = { export type GroupRouteItem = {