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
+8
View File
@@ -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)}
/>
);
})}
+38 -1
View File
@@ -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}
+2
View File
@@ -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 = {