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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user