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:
@@ -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