forked from boos/BoosAPI
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:
@@ -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))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
globalBlockedBrands: [] as string[],
|
||||
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
|
||||
codexHeaderDefaults: {
|
||||
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
|
||||
|
||||
@@ -146,6 +146,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
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');
|
||||
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
|
||||
const next = codexHeaderDefaults as Record<string, unknown>;
|
||||
|
||||
@@ -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 地址');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js';
|
||||
import { config } from '../config.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||
@@ -1139,11 +1141,15 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
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 addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
|
||||
const modelName = (modelNameRaw || '').trim();
|
||||
if (!modelName) return;
|
||||
if (isModelDisabledForSite(siteId, modelName)) return;
|
||||
if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return;
|
||||
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||||
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
|
||||
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
|
||||
|
||||
@@ -421,6 +421,7 @@ export const api = {
|
||||
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||
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 }) }),
|
||||
getSiteAvailableModels: (siteId: number) => request(`/api/sites/${siteId}/available-models`),
|
||||
|
||||
// Accounts
|
||||
getAccounts: () => request('/api/accounts'),
|
||||
@@ -570,6 +571,7 @@ export const api = {
|
||||
method: 'POST', body: JSON.stringify({ oldToken, newToken }),
|
||||
}),
|
||||
getRuntimeSettings: () => request('/api/settings/runtime'),
|
||||
getBrandList: () => request('/api/settings/brand-list'),
|
||||
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -54,6 +54,7 @@ type RuntimeSettings = {
|
||||
proxyTokenMasked?: string;
|
||||
adminIpAllowlist?: string[];
|
||||
currentAdminIp?: string;
|
||||
globalBlockedBrands?: string[];
|
||||
};
|
||||
|
||||
type SystemProxyTestState =
|
||||
@@ -234,6 +235,9 @@ export default function Settings() {
|
||||
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
||||
const [savingRouting, setSavingRouting] = 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 [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
@@ -413,13 +417,15 @@ export default function Settings() {
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([
|
||||
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([
|
||||
api.getAuthInfo(),
|
||||
api.getRuntimeSettings(),
|
||||
api.getDownstreamApiKeys(),
|
||||
api.getRoutesLite(),
|
||||
api.getRuntimeDatabaseConfig(),
|
||||
api.getBrandList(),
|
||||
]);
|
||||
setAllBrandNames(Array.isArray(brandListRes?.brands) ? brandListRes.brands : []);
|
||||
setMaskedToken(authInfo.masked || '****');
|
||||
setRuntime({
|
||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||
@@ -451,7 +457,9 @@ export default function Settings() {
|
||||
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
|
||||
: [],
|
||||
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
|
||||
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
|
||||
});
|
||||
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
|
||||
setProxyErrorKeywordsText(
|
||||
Array.isArray(runtimeInfo.proxyErrorKeywords)
|
||||
? 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 () => {
|
||||
setSavingSecurity(true);
|
||||
try {
|
||||
@@ -1426,7 +1456,51 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Brand Filter */}
|
||||
<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={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
||||
|
||||
+175
-38
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { getBrand } from '../components/BrandIcon.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import MobileBatchBar from '../components/MobileBatchBar.js';
|
||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
@@ -222,6 +223,8 @@ export default function Sites() {
|
||||
const [disabledModelInput, setDisabledModelInput] = useState('');
|
||||
const [disabledModelsLoading, setDisabledModelsLoading] = useState(false);
|
||||
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
||||
|
||||
if (editor) lastEditorRef.current = editor;
|
||||
const activeEditor = editor || lastEditorRef.current;
|
||||
@@ -326,12 +329,20 @@ export default function Sites() {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
scrollToEditorTop();
|
||||
// Load disabled models for this site
|
||||
// Load disabled models and available models for this site
|
||||
setDisabledModelsLoading(true);
|
||||
setDisabledModels([]);
|
||||
setDisabledModelInput('');
|
||||
api.getSiteDisabledModels(site.id)
|
||||
.then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : []))
|
||||
setAvailableModels([]);
|
||||
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(() => { })
|
||||
.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={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>禁用模型管理</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。
|
||||
在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。勾选表示禁用该模型。
|
||||
</div>
|
||||
{disabledModelsLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
@@ -921,34 +932,155 @@ export default function Sites() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10, minHeight: 24 }}>
|
||||
{disabledModels.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无禁用模型</span>
|
||||
)}
|
||||
{disabledModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="badge badge-muted"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDisabledModel(model)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
title={`移除 ${model}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Search and brand group controls */}
|
||||
{availableModels.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
key={model}
|
||||
className="badge badge-muted"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '3px 8px' }}
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDisabledModel(model)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
fontSize: 13, lineHeight: 1, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
title={`移除 ${model}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Manual input for models not in the list */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
placeholder="输入模型名称,如 gpt-4o"
|
||||
placeholder="手动输入模型名称,如 gpt-4o"
|
||||
value={disabledModelInput}
|
||||
onChange={(e) => setDisabledModelInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }}
|
||||
@@ -962,14 +1094,19 @@ export default function Sites() {
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveDisabledModels}
|
||||
disabled={disabledModelsSaving}
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 16px' }}
|
||||
>
|
||||
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
|
||||
<button
|
||||
onClick={handleSaveDisabledModels}
|
||||
disabled={disabledModelsSaving}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '6px 16px' }}
|
||||
>
|
||||
{disabledModelsSaving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存禁用列表'}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
已禁用 {disabledModels.length} 个模型
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
getModelPatternError,
|
||||
} from './token-routes/utils.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 RouteCard from './token-routes/RouteCard.js';
|
||||
import AddChannelModal from './token-routes/AddChannelModal.js';
|
||||
@@ -119,6 +119,7 @@ export default function TokenRoutes() {
|
||||
const [activeSite, setActiveSite] = useState<string | null>(null);
|
||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all');
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(true);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
|
||||
@@ -640,6 +641,16 @@ export default function TokenRoutes() {
|
||||
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
|
||||
}, [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(() => (
|
||||
[...listVisibleRoutes].sort((a, b) => {
|
||||
if (sortBy === 'channelCount') {
|
||||
@@ -655,6 +666,12 @@ export default function TokenRoutes() {
|
||||
const filteredRoutes = useMemo(() => {
|
||||
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__') {
|
||||
list = list.filter((route) => !isRouteExactModel(route));
|
||||
} else if (typeof activeGroupFilter === 'number') {
|
||||
@@ -694,6 +711,7 @@ export default function TokenRoutes() {
|
||||
return list;
|
||||
}, [
|
||||
sortedRoutes,
|
||||
enabledFilter,
|
||||
activeGroupFilter,
|
||||
activeBrand,
|
||||
activeSite,
|
||||
@@ -713,6 +731,8 @@ export default function TokenRoutes() {
|
||||
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const shouldShowLoadMore = filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length;
|
||||
|
||||
useEffect(() => {
|
||||
const el = loadMoreSentinelRef.current;
|
||||
if (!el) return;
|
||||
@@ -722,7 +742,7 @@ export default function TokenRoutes() {
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [handleLoadMoreRoutes]);
|
||||
}, [handleLoadMoreRoutes, shouldShowLoadMore]);
|
||||
|
||||
const visibleRoutes = useMemo(
|
||||
() => filteredRoutes.slice(0, visibleRouteCount),
|
||||
@@ -763,12 +783,45 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
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 {
|
||||
await api.deleteChannel(channelId);
|
||||
toast.success('通道已移除');
|
||||
// Reload channels for this route
|
||||
await loadChannels(routeId, true);
|
||||
// Update channel count in summary
|
||||
setRouteSummaries((prev) =>
|
||||
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 tokenId = channelTokenDraft[channelId];
|
||||
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 isCurrentlyExpanded = expandedRouteIds.includes(routeId);
|
||||
if (isCurrentlyExpanded) {
|
||||
@@ -985,6 +1084,12 @@ export default function TokenRoutes() {
|
||||
(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);
|
||||
handleChannelDragEndRef.current = handleChannelDragEnd;
|
||||
const stableChannelDragEnd = useCallback(
|
||||
@@ -997,6 +1102,12 @@ export default function TokenRoutes() {
|
||||
(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
|
||||
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
||||
@@ -1133,6 +1244,9 @@ export default function TokenRoutes() {
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
enabledFilter={enabledFilter}
|
||||
setEnabledFilter={setEnabledFilter}
|
||||
enabledCounts={enabledCounts}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
@@ -1153,6 +1267,9 @@ export default function TokenRoutes() {
|
||||
setActiveEndpointType={setActiveEndpointType}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
setActiveGroupFilter={setActiveGroupFilter}
|
||||
enabledFilter={enabledFilter}
|
||||
setEnabledFilter={setEnabledFilter}
|
||||
enabledCounts={enabledCounts}
|
||||
brandList={brandList}
|
||||
siteList={siteList}
|
||||
endpointTypeList={endpointTypeList}
|
||||
@@ -1311,11 +1428,13 @@ export default function TokenRoutes() {
|
||||
onTokenDraftChange={stableTokenDraftChange}
|
||||
onSaveToken={stableChannelTokenSave}
|
||||
onDeleteChannel={stableDeleteChannel}
|
||||
onToggleChannelEnabled={stableToggleChannelEnabled}
|
||||
onChannelDragEnd={stableChannelDragEnd}
|
||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||
onAddChannel={stableAddChannel}
|
||||
onSiteBlockModel={stableSiteBlockModel}
|
||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||
onToggleSourceGroup={stableToggleSourceGroup}
|
||||
/>
|
||||
@@ -1323,7 +1442,7 @@ export default function TokenRoutes() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||
{shouldShowLoadMore && (
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
factoryReset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -77,6 +78,7 @@ describe('Settings factory reset', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
triggerCheckinAll: vi.fn(),
|
||||
},
|
||||
@@ -62,6 +63,7 @@ describe('Settings log cleanup schedule', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getDownstreamApiKeys: vi.fn(),
|
||||
getRoutesLite: vi.fn(),
|
||||
getRuntimeDatabaseConfig: vi.fn(),
|
||||
getBrandList: vi.fn(),
|
||||
updateRuntimeSettings: vi.fn(),
|
||||
testSystemProxy: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +60,7 @@ describe('Settings system proxy', () => {
|
||||
});
|
||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||
apiMock.getBrandList.mockResolvedValue({ brands: [] });
|
||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||
saved: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
getSites: vi.fn(),
|
||||
addSite: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn(),
|
||||
getSiteAvailableModels: vi.fn(),
|
||||
updateSiteDisabledModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
},
|
||||
@@ -57,6 +58,7 @@ describe('Sites disabled models save', () => {
|
||||
},
|
||||
]);
|
||||
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
|
||||
apiMock.getSiteAvailableModels.mockResolvedValue({ models: [] });
|
||||
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getSites: vi.fn(),
|
||||
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ type RouteCardProps = {
|
||||
onTokenDraftChange: (channelId: number, tokenId: number) => void;
|
||||
onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
|
||||
onDeleteChannel: (channelId: number, routeId: number) => void;
|
||||
onToggleChannelEnabled: (channelId: number, routeId: number, enabled: boolean) => void;
|
||||
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
|
||||
// Missing token hints
|
||||
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
|
||||
@@ -68,6 +69,8 @@ type RouteCardProps = {
|
||||
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
|
||||
// Add channel
|
||||
onAddChannel: (routeId: number) => void;
|
||||
// Site block model
|
||||
onSiteBlockModel: (channelId: number, routeId: number) => void;
|
||||
// Source group expansion
|
||||
expandedSourceGroupMap: Record<string, boolean>;
|
||||
onToggleSourceGroup: (groupKey: string) => void;
|
||||
@@ -119,11 +122,13 @@ function RouteCardInner({
|
||||
onTokenDraftChange,
|
||||
onSaveToken,
|
||||
onDeleteChannel,
|
||||
onToggleChannelEnabled,
|
||||
onChannelDragEnd,
|
||||
missingTokenSiteItems,
|
||||
missingTokenGroupItems,
|
||||
onCreateTokenForMissing,
|
||||
onAddChannel,
|
||||
onSiteBlockModel,
|
||||
expandedSourceGroupMap,
|
||||
onToggleSourceGroup,
|
||||
}: RouteCardProps) {
|
||||
@@ -548,6 +553,7 @@ function RouteCardInner({
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -572,6 +578,8 @@ function RouteCardInner({
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)}
|
||||
onSiteBlockModel={() => onSiteBlockModel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { tr } from '../../i18n.js';
|
||||
import type { GroupFilter, GroupRouteItem } from './types.js';
|
||||
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
|
||||
|
||||
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
|
||||
|
||||
type RouteFilterBarProps = {
|
||||
totalRouteCount: number;
|
||||
activeBrand: string | null;
|
||||
@@ -15,6 +17,9 @@ type RouteFilterBarProps = {
|
||||
setActiveEndpointType: (endpointType: string | null) => void;
|
||||
activeGroupFilter: GroupFilter;
|
||||
setActiveGroupFilter: (filter: GroupFilter) => void;
|
||||
enabledFilter: EnabledFilter;
|
||||
setEnabledFilter: (filter: EnabledFilter) => void;
|
||||
enabledCounts: { enabled: number; disabled: number };
|
||||
brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
|
||||
siteList: [string, { count: number; siteId: number }][];
|
||||
endpointTypeList: [string, number][];
|
||||
@@ -63,8 +68,11 @@ function ActiveFilterSummary({
|
||||
activeSite,
|
||||
activeGroupFilter,
|
||||
activeEndpointType,
|
||||
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType'>) {
|
||||
enabledFilter,
|
||||
}: Pick<RouteFilterBarProps, 'activeBrand' | 'activeSite' | 'activeGroupFilter' | 'activeEndpointType' | 'enabledFilter'>) {
|
||||
const tags: string[] = [];
|
||||
if (enabledFilter === 'enabled') tags.push('状态=启用');
|
||||
else if (enabledFilter === 'disabled') tags.push('状态=禁用');
|
||||
if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
|
||||
if (activeSite) tags.push(`站点=${activeSite}`);
|
||||
if (activeGroupFilter === '__all__') tags.push('群组=全部');
|
||||
@@ -86,6 +94,9 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
setActiveEndpointType,
|
||||
activeGroupFilter,
|
||||
setActiveGroupFilter,
|
||||
enabledFilter,
|
||||
setEnabledFilter,
|
||||
enabledCounts,
|
||||
brandList,
|
||||
siteList,
|
||||
endpointTypeList,
|
||||
@@ -117,12 +128,38 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
activeSite={activeSite}
|
||||
activeGroupFilter={activeGroupFilter}
|
||||
activeEndpointType={activeEndpointType}
|
||||
enabledFilter={enabledFilter}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded panel */}
|
||||
{expandPresence.shouldRender && (
|
||||
<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 */}
|
||||
<FilterRow label={tr('品牌')}>
|
||||
<FilterChip
|
||||
|
||||
@@ -25,6 +25,8 @@ export function SortableChannelRow({
|
||||
onTokenDraftChange,
|
||||
onSaveToken,
|
||||
onDeleteChannel,
|
||||
onToggleEnabled,
|
||||
onSiteBlockModel,
|
||||
}: SortableChannelRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -42,7 +44,7 @@ export function SortableChannelRow({
|
||||
const rowStyle: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.72 : 1,
|
||||
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||
@@ -376,6 +378,10 @@ export function SortableChannelRow({
|
||||
</span>
|
||||
) : 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' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
|
||||
@@ -502,11 +508,31 @@ export function SortableChannelRow({
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
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
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -161,6 +161,8 @@ export type SortableChannelRowProps = {
|
||||
onTokenDraftChange: (channelId: number, tokenId: number) => void;
|
||||
onSaveToken: () => void;
|
||||
onDeleteChannel: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onSiteBlockModel?: () => void;
|
||||
};
|
||||
|
||||
export type GroupRouteItem = {
|
||||
|
||||
Reference in New Issue
Block a user