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 {
|
try {
|
||||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
if (Array.isArray(parsed)) {
|
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 {
|
} catch {
|
||||||
return;
|
return;
|
||||||
@@ -965,9 +977,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.globalBlockedBrands !== undefined) {
|
if (body.globalBlockedBrands !== undefined) {
|
||||||
const nextBrands = Array.isArray(body.globalBlockedBrands)
|
if (!Array.isArray(body.globalBlockedBrands)) {
|
||||||
? body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean)
|
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 uniqueBrands = Array.from(new Set(nextBrands));
|
||||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||||
const next = JSON.stringify(uniqueBrands);
|
const next = JSON.stringify(uniqueBrands);
|
||||||
@@ -976,6 +989,16 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
config.globalBlockedBrands = uniqueBrands;
|
config.globalBlockedBrands = uniqueBrands;
|
||||||
upsertSetting('global_blocked_brands', JSON.stringify(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) {
|
if (body.webhookUrl !== undefined) {
|
||||||
|
|||||||
@@ -534,9 +534,9 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
.all();
|
.all();
|
||||||
|
|
||||||
const models = Array.from(new Set([
|
const models = Array.from(new Set([
|
||||||
...accountModels.map((r) => r.modelName),
|
...accountModels.map((r) => r.modelName.trim()),
|
||||||
...tokenModels.map((r) => r.modelName),
|
...tokenModels.map((r) => r.modelName.trim()),
|
||||||
])).filter((m) => m.trim().length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
|
||||||
return { siteId: id, models };
|
return { siteId: id, models };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean {
|
|||||||
case 'segment':
|
case 'segment':
|
||||||
return ctx.segments.includes(rule.keyword);
|
return ctx.segments.includes(rule.keyword);
|
||||||
case 'boundary': {
|
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));
|
return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c));
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -417,15 +417,21 @@ export default function Settings() {
|
|||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo, brandListRes] = await Promise.all([
|
const [authInfo, runtimeInfo, downstreamInfo, routeRows, runtimeDatabaseInfo] = 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 : []);
|
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 || '****');
|
setMaskedToken(authInfo.masked || '****');
|
||||||
setRuntime({
|
setRuntime({
|
||||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||||
@@ -1469,6 +1475,8 @@ export default function Settings() {
|
|||||||
<button
|
<button
|
||||||
key={brand}
|
key={brand}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isBlocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
setBlockedBrands((prev) => prev.filter((b) => b !== brand));
|
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 disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||||
|
|
||||||
const brandGroups = useMemo(() => {
|
const brandGroups = useMemo(() => {
|
||||||
|
const allModels = Array.from(new Set([...availableModels, ...disabledModels]));
|
||||||
const groups = new Map<string, string[]>();
|
const groups = new Map<string, string[]>();
|
||||||
for (const model of availableModels) {
|
for (const model of allModels) {
|
||||||
const brand = getBrand(model);
|
const brand = getBrand(model);
|
||||||
const brandName = brand?.name || '其他';
|
const brandName = brand?.name || '其他';
|
||||||
if (!groups.has(brandName)) groups.set(brandName, []);
|
if (!groups.has(brandName)) groups.set(brandName, []);
|
||||||
@@ -240,7 +241,7 @@ export default function Sites() {
|
|||||||
if (b[0] === '其他') return -1;
|
if (b[0] === '其他') return -1;
|
||||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||||
});
|
});
|
||||||
}, [availableModels]);
|
}, [availableModels, disabledModels]);
|
||||||
|
|
||||||
const filteredBrandGroups = useMemo(() => {
|
const filteredBrandGroups = useMemo(() => {
|
||||||
const q = disabledModelSearch.trim().toLowerCase();
|
const q = disabledModelSearch.trim().toLowerCase();
|
||||||
@@ -354,6 +355,7 @@ export default function Sites() {
|
|||||||
setForm(siteFormFromSite(site));
|
setForm(siteFormFromSite(site));
|
||||||
scrollToEditorTop();
|
scrollToEditorTop();
|
||||||
// Load disabled models and available models for this site
|
// Load disabled models and available models for this site
|
||||||
|
const loadSiteId = site.id;
|
||||||
setDisabledModelsLoading(true);
|
setDisabledModelsLoading(true);
|
||||||
setDisabledModels([]);
|
setDisabledModels([]);
|
||||||
setAvailableModels([]);
|
setAvailableModels([]);
|
||||||
@@ -363,11 +365,18 @@ export default function Sites() {
|
|||||||
api.getSiteAvailableModels(site.id),
|
api.getSiteAvailableModels(site.id),
|
||||||
])
|
])
|
||||||
.then(([disabledRes, availableRes]: any[]) => {
|
.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 : []);
|
setDisabledModels(Array.isArray(disabledRes?.models) ? disabledRes.models : []);
|
||||||
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
|
setAvailableModels(Array.isArray(availableRes?.models) ? availableRes.models : []);
|
||||||
})
|
})
|
||||||
.catch(() => { })
|
.catch((err: any) => {
|
||||||
.finally(() => setDisabledModelsLoading(false));
|
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 () => {
|
const handleSaveDisabledModels = async () => {
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
const candidatesLoadedRef = useRef(false);
|
const candidatesLoadedRef = useRef(false);
|
||||||
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
|
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
const candidatesVersionRef = useRef(0);
|
||||||
|
|
||||||
const loadRouteDecisions = async (
|
const loadRouteDecisions = async (
|
||||||
routeRows: RouteSummaryRow[],
|
routeRows: RouteSummaryRow[],
|
||||||
@@ -248,6 +249,7 @@ export default function TokenRoutes() {
|
|||||||
);
|
);
|
||||||
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
|
||||||
});
|
});
|
||||||
|
candidatesVersionRef.current = Date.now();
|
||||||
} catch {
|
} catch {
|
||||||
candidatesLoadedRef.current = false;
|
candidatesLoadedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -298,12 +300,14 @@ export default function TokenRoutes() {
|
|||||||
const res = await api.rebuildRoutes(true);
|
const res = await api.rebuildRoutes(true);
|
||||||
if (res?.queued) {
|
if (res?.queued) {
|
||||||
toast.info(res.message || '已开始重建路由,请稍后查看日志');
|
toast.info(res.message || '已开始重建路由,请稍后查看日志');
|
||||||
|
invalidateChannels();
|
||||||
await load();
|
await load();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
const createdRoutes = res?.rebuild?.createdRoutes ?? 0;
|
||||||
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
const createdChannels = res?.rebuild?.createdChannels ?? 0;
|
||||||
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
toast.success(`自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)`);
|
||||||
|
invalidateChannels();
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '重建路由失败');
|
toast.error(e.message || '重建路由失败');
|
||||||
@@ -778,7 +782,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route candidate index — only computes for routes actually accessed
|
// 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 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) {
|
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
||||||
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -797,7 +801,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route missing token index
|
// Lazy per-route missing token index
|
||||||
const missingTokenCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
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) {
|
if (missingTokenCacheRef.current.key !== missingTokenCacheKey) {
|
||||||
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -816,7 +820,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route missing token group index
|
// Lazy per-route missing token group index
|
||||||
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
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) {
|
if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) {
|
||||||
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -897,12 +901,16 @@ export default function TokenRoutes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => {
|
const handleToggleChannelEnabled = async (channelId: number, routeId: number, enabled: boolean) => {
|
||||||
|
if (updatingChannel[channelId]) return;
|
||||||
|
setUpdatingChannel((prev) => ({ ...prev, [channelId]: true }));
|
||||||
try {
|
try {
|
||||||
await api.updateChannel(channelId, { enabled });
|
await api.updateChannel(channelId, { enabled });
|
||||||
toast.success(enabled ? '通道已启用' : '通道已禁用');
|
toast.success(enabled ? '通道已启用' : '通道已禁用');
|
||||||
await loadChannels(routeId, true);
|
await loadChannels(routeId, true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '更新通道状态失败');
|
toast.error(e.message || '更新通道状态失败');
|
||||||
|
} finally {
|
||||||
|
setUpdatingChannel((prev) => ({ ...prev, [channelId]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1024,6 +1032,7 @@ export default function TokenRoutes() {
|
|||||||
await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]);
|
await api.updateSiteDisabledModels(siteId, [...currentModels, modelName]);
|
||||||
toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`);
|
toast.success(`已将「${modelName}」加入站点「${siteName}」的禁用列表,正在重建路由...`);
|
||||||
await api.rebuildRoutes(false);
|
await api.rebuildRoutes(false);
|
||||||
|
invalidateChannels();
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '站点屏蔽模型失败');
|
toast.error(e.message || '站点屏蔽模型失败');
|
||||||
@@ -1324,7 +1333,7 @@ export default function TokenRoutes() {
|
|||||||
</button>
|
</button>
|
||||||
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
|
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
|
||||||
<RouteFilterBar
|
<RouteFilterBar
|
||||||
totalRouteCount={listVisibleRoutes.length}
|
totalRouteCount={baseFilteredRoutes.length}
|
||||||
activeBrand={activeBrand}
|
activeBrand={activeBrand}
|
||||||
setActiveBrand={setActiveBrand}
|
setActiveBrand={setActiveBrand}
|
||||||
activeSite={activeSite}
|
activeSite={activeSite}
|
||||||
@@ -1347,7 +1356,7 @@ export default function TokenRoutes() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<RouteFilterBar
|
<RouteFilterBar
|
||||||
totalRouteCount={listVisibleRoutes.length}
|
totalRouteCount={baseFilteredRoutes.length}
|
||||||
activeBrand={activeBrand}
|
activeBrand={activeBrand}
|
||||||
setActiveBrand={setActiveBrand}
|
setActiveBrand={setActiveBrand}
|
||||||
activeSite={activeSite}
|
activeSite={activeSite}
|
||||||
|
|||||||
Reference in New Issue
Block a user