1
0
forked from boos/BoosAPI

fix(web): fix route card title overflow and optimize with React.memo

Layout fixes:
- Title uses flex:1 + text-overflow:ellipsis, badges always right-aligned
- Enable button has minWidth for cross-card vertical alignment
- Icon and badges use flexShrink:0 to prevent compression

Performance:
- Wrap RouteCard with React.memo to skip re-renders for unchanged routes
- Change callback props to accept routeId for stable reference compatibility
This commit is contained in:
Hureru
2026-03-11 14:04:04 +08:00
parent 69cc491d3a
commit 49dbaf3c6a
+30 -29
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react'; import { memo, useState, type ReactNode } from 'react';
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -30,10 +30,10 @@ type RouteCardProps = {
route: RouteSummaryRow; route: RouteSummaryRow;
brand: BrandInfo | null; brand: BrandInfo | null;
expanded: boolean; expanded: boolean;
onToggleExpand: () => void; onToggleExpand: (routeId: number) => void;
onEdit: () => void; onEdit: (route: RouteSummaryRow) => void;
onDelete: () => void; onDelete: (routeId: number) => void;
onToggleEnabled: () => void; onToggleEnabled: (route: RouteSummaryRow) => void;
// Channel data (loaded on demand) // Channel data (loaded on demand)
channels: RouteChannel[] | undefined; channels: RouteChannel[] | undefined;
loadingChannels: boolean; loadingChannels: boolean;
@@ -46,14 +46,14 @@ type RouteCardProps = {
updatingChannel: Record<number, boolean>; updatingChannel: Record<number, boolean>;
savingPriority: boolean; savingPriority: boolean;
onTokenDraftChange: (channelId: number, tokenId: number) => void; onTokenDraftChange: (channelId: number, tokenId: number) => void;
onSaveToken: (channelId: number, accountId: number) => void; onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
onDeleteChannel: (channelId: number) => void; onDeleteChannel: (channelId: number, routeId: number) => void;
onChannelDragEnd: (event: DragEndEvent) => void; onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
// Missing token hints // Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
onCreateTokenForMissing: (accountId: number, modelName: string) => void; onCreateTokenForMissing: (accountId: number, modelName: string) => void;
// Add channel // Add channel
onAddChannel: () => void; onAddChannel: (routeId: number) => void;
// Source group expansion // Source group expansion
expandedSourceGroupMap: Record<string, boolean>; expandedSourceGroupMap: Record<string, boolean>;
onToggleSourceGroup: (groupKey: string) => void; onToggleSourceGroup: (groupKey: string) => void;
@@ -71,7 +71,7 @@ function AnimatedCollapseSection({ open, children }: { open: boolean; children:
); );
} }
export default function RouteCard({ function RouteCardInner({
route, route,
brand, brand,
expanded, expanded,
@@ -135,11 +135,11 @@ export default function RouteCard({
return ( return (
<div <div
className="card route-card-collapsed" className="card route-card-collapsed"
onClick={onToggleExpand} onClick={() => onToggleExpand(route.id)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', width: 20, height: 20 }}> <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0, width: 20, height: 20 }}>
{routeIcon.kind === 'brand' ? ( {routeIcon.kind === 'brand' ? (
<BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} /> <BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} />
) : routeIcon.kind === 'text' ? ( ) : routeIcon.kind === 'text' ? (
@@ -151,32 +151,30 @@ export default function RouteCard({
)} )}
</span> </span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)' }}> <code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{title} {title}
</code> </code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? ( {route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span> <span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span>
) : null} ) : null}
<button <button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`} className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }} style={{ fontSize: 11, cursor: 'pointer', border: 'none', flexShrink: 0, minWidth: 36, textAlign: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(); }} onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'} data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
> >
{route.enabled ? tr('启用') : tr('禁用')} {route.enabled ? tr('启用') : tr('禁用')}
</button> </button>
<span className="badge badge-info" style={{ fontSize: 10 }}> <span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
<div style={{ flex: 1 }} />
<svg <svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ color: 'var(--color-text-muted)' }} style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
aria-hidden aria-hidden
> >
<path d="m5 7 5 6 5-6" /> <path d="m5 7 5 6 5-6" />
@@ -212,7 +210,7 @@ export default function RouteCard({
<button <button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`} className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }} style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(); }} onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'} data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
> >
{route.enabled ? tr('启用') : tr('禁用')} {route.enabled ? tr('启用') : tr('禁用')}
@@ -227,11 +225,11 @@ export default function RouteCard({
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!exactRoute && ( {!exactRoute && (
<button onClick={onEdit} className="btn btn-link">{tr('编辑群组')}</button> <button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)} )}
<button onClick={onDelete} className="btn btn-link btn-link-danger">{tr('删除路由')}</button> <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
<button <button
onClick={onToggleExpand} onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost" className="btn btn-ghost"
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }} style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
data-tooltip={tr('收起')} data-tooltip={tr('收起')}
@@ -273,7 +271,7 @@ export default function RouteCard({
</div> </div>
) : <div />} ) : <div />}
<button <button
onClick={onAddChannel} onClick={() => onAddChannel(route.id)}
className="btn btn-ghost" className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }} style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
> >
@@ -289,7 +287,7 @@ export default function RouteCard({
</div> </div>
) : channels && channels.length > 0 ? ( ) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onChannelDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => onChannelDragEnd(route.id, event)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channelGroups.map((group) => { {channelGroups.map((group) => {
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || ''); const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
@@ -357,8 +355,8 @@ export default function RouteCard({
activeTokenId={activeTokenId} activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]} isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(channel.id, channel.accountId)} onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id)} onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/> />
); );
})} })}
@@ -381,3 +379,6 @@ export default function RouteCard({
</div> </div>
); );
} }
const RouteCard = memo(RouteCardInner);
export default RouteCard;