fix: address review issues across settings, sites, brand matcher, and token routes
- settings.ts: trigger route rebuild after globalBlockedBrands changes in both import and PUT handler; reject non-array payloads with 400 instead of silently defaulting to empty array - sites.ts: trim model names before deduplication so whitespace variants merge - brandMatcher.ts: align boundary regex with frontend delimiter class [/:_\-\s] - Settings.tsx: add role="switch" and aria-checked to brand filter chips; make getBrandList best-effort so its failure doesn't block page load - Sites.tsx: build brandGroups from union of availableModels and disabledModels so legacy/removed models remain visible; add stale-response guard and error logging for disabled/available model fetches - TokenRoutes.tsx: use baseFilteredRoutes.length for totalRouteCount to keep enabled/disabled counts in sync with status filter; add candidatesVersionRef to invalidate lazy caches on payload change; add per-channel in-flight guard on toggle; invalidate channel cache after rebuild and site-block operations
This commit is contained in:
@@ -401,7 +401,19 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
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);
|
||||
const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||
config.globalBlockedBrands = nextBrands;
|
||||
if (prev !== JSON.stringify(nextBrands)) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
@@ -965,9 +977,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
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)
|
||||
: [];
|
||||
if (!Array.isArray(body.globalBlockedBrands)) {
|
||||
return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' });
|
||||
}
|
||||
const nextBrands = 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);
|
||||
@@ -976,6 +989,16 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
config.globalBlockedBrands = uniqueBrands;
|
||||
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.webhookUrl !== undefined) {
|
||||
|
||||
@@ -534,9 +534,9 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
.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' }));
|
||||
...accountModels.map((r) => r.modelName.trim()),
|
||||
...tokenModels.map((r) => r.modelName.trim()),
|
||||
])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
|
||||
return { siteId: id, models };
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean {
|
||||
case 'segment':
|
||||
return ctx.segments.includes(rule.keyword);
|
||||
case 'boundary': {
|
||||
const pattern = new RegExp(`(^|[^a-z0-9])${rule.keyword}(?=$|[^a-z0-9])`);
|
||||
const pattern = new RegExp(`(^|[/:_\\-\\s])${rule.keyword}(?=$|[/:_\\-\\s])`);
|
||||
return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c));
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -417,15 +417,21 @@ export default function Settings() {
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([
|
||||
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = await Promise.all([
|
||||
api.getAuthInfo(),
|
||||
api.getRuntimeSettings(),
|
||||
api.getDownstreamApiKeys(),
|
||||
api.getRoutesLite(),
|
||||
api.getRuntimeDatabaseConfig(),
|
||||
api.getBrandList(),
|
||||
]);
|
||||
setAllBrandNames(Array.isArray(brandListRes?.brands) ? brandListRes.brands : []);
|
||||
let brandListBrands: string[] = [];
|
||||
try {
|
||||
const brandListRes = await api.getBrandList();
|
||||
brandListBrands = Array.isArray(brandListRes?.brands) ? brandListRes.brands : [];
|
||||
} catch {
|
||||
// best-effort: brand list is non-critical
|
||||
}
|
||||
setAllBrandNames(brandListBrands);
|
||||
setMaskedToken(authInfo.masked || '****');
|
||||
setRuntime({
|
||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||
@@ -1469,6 +1475,8 @@ export default function Settings() {
|
||||
<button
|
||||
key={brand}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isBlocked}
|
||||
onClick={() => {
|
||||
if (isBlocked) {
|
||||
setBlockedBrands((prev) => prev.filter((b) => b !== brand));
|
||||
|
||||
+13
-4
@@ -228,8 +228,9 @@ export default function Sites() {
|
||||
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||
|
||||
const brandGroups = useMemo(() => {
|
||||
const allModels = Array.from(new Set([...availableModels, ...disabledModels]));
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const model of availableModels) {
|
||||
for (const model of allModels) {
|
||||
const brand = getBrand(model);
|
||||
const brandName = brand?.name || '其他';
|
||||
if (!groups.has(brandName)) groups.set(brandName, []);
|
||||
@@ -240,7 +241,7 @@ export default function Sites() {
|
||||
if (b[0] === '其他') return -1;
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}, [availableModels]);
|
||||
}, [availableModels, disabledModels]);
|
||||
|
||||
const filteredBrandGroups = useMemo(() => {
|
||||
const q = disabledModelSearch.trim().toLowerCase();
|
||||
@@ -354,6 +355,7 @@ export default function Sites() {
|
||||
setForm(siteFormFromSite(site));
|
||||
scrollToEditorTop();
|
||||
// Load disabled models and available models for this site
|
||||
const loadSiteId = site.id;
|
||||
setDisabledModelsLoading(true);
|
||||
setDisabledModels([]);
|
||||
setAvailableModels([]);
|
||||
@@ -363,11 +365,18 @@ export default function Sites() {
|
||||
api.getSiteAvailableModels(site.id),
|
||||
])
|
||||
.then(([disabledRes, availableRes]: any[]) => {
|
||||
// Guard: only apply if we're still editing the same site
|
||||
if (editor?.mode !== 'edit' && loadSiteId !== site.id) return;
|
||||
setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []);
|
||||
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
|
||||
})
|
||||
.catch(() => { })
|
||||
.finally(() => setDisabledModelsLoading(false));
|
||||
.catch((err: any) => {
|
||||
console.warn('Failed to load site models:', err?.message || err);
|
||||
// Preserve previous (empty) model lists — don't clear UI silently
|
||||
})
|
||||
.finally(() => {
|
||||
setDisabledModelsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDisabledModels = async () => {
|
||||
|
||||
@@ -163,6 +163,7 @@ export default function TokenRoutes() {
|
||||
|
||||
const candidatesLoadedRef = useRef(false);
|
||||
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const candidatesVersionRef = useRef(0);
|
||||
|
||||
const loadRouteDecisions = async (
|
||||
routeRows: RouteSummaryRow[],
|
||||
@@ -248,6 +249,7 @@ export default function TokenRoutes() {
|
||||
);
|
||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||
});
|
||||
candidatesVersionRef.current = Date.now();
|
||||
} catch {
|
||||
candidatesLoadedRef.current = false;
|
||||
} finally {
|
||||
@@ -298,12 +300,14 @@ export default function TokenRoutes() {
|
||||
const res = await api.rebuildRoutes(true);
|
||||
if (res?.queued) {
|
||||
toast.info(res.message || '已开始重建路由,请稍后查看日志');
|
||||
invalidateChannels();
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
||||
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
||||
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
||||
invalidateChannels();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '重建路由失败');
|
||||
@@ -778,7 +782,7 @@ export default function TokenRoutes() {
|
||||
|
||||
// Lazy per-route candidate index — only computes for routes actually accessed
|
||||
const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
|
||||
const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}`;
|
||||
const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}|${candidatesVersionRef.current}`;
|
||||
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
||||
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
||||
}
|
||||
@@ -797,7 +801,7 @@ export default function TokenRoutes() {
|
||||
|
||||
// Lazy per-route missing token index
|
||||
const missingTokenCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||
const missingTokenCacheKey = `${routePatternsKey}|${Object.keys(missingTokenModelsByName).length}`;
|
||||
const missingTokenCacheKey = `${routePatternsKey}|${Object.keys(missingTokenModelsByName).length}|${candidatesVersionRef.current}`;
|
||||
if (missingTokenCacheRef.current.key !== missingTokenCacheKey) {
|
||||
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
||||
}
|
||||
@@ -816,7 +820,7 @@ export default function TokenRoutes() {
|
||||
|
||||
// Lazy per-route missing token group index
|
||||
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||
const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}`;
|
||||
const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}|${candidatesVersionRef.current}`;
|
||||
if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) {
|
||||
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
||||
}
|
||||
@@ -897,12 +901,16 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => {
|
||||
if (updatingChannel[channelId]) return;
|
||||
setUpdatingChannel((prev) => ({ ...prev, [channelId]: true }));
|
||||
try {
|
||||
await api.updateChannel(channelId, { enabled });
|
||||
toast.success(enabled ? '通道已启用' : '通道已禁用');
|
||||
await loadChannels(routeId, true);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '更新通道状态失败');
|
||||
} finally {
|
||||
setUpdatingChannel((prev) => ({ ...prev, [channelId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1024,6 +1032,7 @@ export default function TokenRoutes() {
|
||||
await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]);
|
||||
toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`);
|
||||
await api.rebuildRoutes(false);
|
||||
invalidateChannels();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '站点屏蔽模型失败');
|
||||
@@ -1324,7 +1333,7 @@ export default function TokenRoutes() {
|
||||
</button>
|
||||
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
totalRouteCount={baseFilteredRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
@@ -1347,7 +1356,7 @@ export default function TokenRoutes() {
|
||||
</>
|
||||
) : (
|
||||
<RouteFilterBar
|
||||
totalRouteCount={listVisibleRoutes.length}
|
||||
totalRouteCount={baseFilteredRoutes.length}
|
||||
activeBrand={activeBrand}
|
||||
setActiveBrand={setActiveBrand}
|
||||
activeSite={activeSite}
|
||||
|
||||
Reference in New Issue
Block a user