1
0
forked from boos/BoosAPI

[codex] refine token routes workspace motion and hierarchy (#394)

* refine token routes workspace motion and hierarchy

* address route workspace review feedback

* Refine token route density and motion

* [codex] refine token routes motion behavior

* [codex] address route motion review comments

* [codex] tighten route row interaction states
This commit is contained in:
Cita
2026-04-03 11:04:40 +08:00
committed by GitHub
parent af5b62fd8b
commit bc3893ff3b
14 changed files with 2475 additions and 738 deletions
+191 -70
View File
@@ -4597,6 +4597,24 @@ textarea {
color: var(--color-text-muted);
}
.route-batch-bar {
position: sticky;
top: calc(var(--topbar-height) + 8px);
z-index: 50;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent);
border-radius: 14px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, white 6%), color-mix(in srgb, var(--color-bg-card) 98%, white 2%));
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.04);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.proxy-logs-filter-select {
min-width: 180px;
}
@@ -5625,91 +5643,62 @@ textarea {
/* ===== Route Filter Bar (Collapsible, horizontal chips) ===== */
.route-filter-bar {
margin-bottom: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
margin-bottom: 8px;
border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent);
border-radius: 14px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, white 6%), color-mix(in srgb, var(--color-bg-card) 99%, white 1%));
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03);
overflow: hidden;
}
.route-filter-bar-summary {
display: flex;
align-items: center;
gap: 8px;
gap: 7px;
width: 100%;
padding: 8px 12px;
padding: 8px 10px;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
font-size: 12.5px;
color: var(--color-text-secondary);
text-align: left;
border-radius: var(--radius-sm);
transition: background 0.15s;
transition: background-color 0.15s ease, color 0.15s ease, transform 0.18s ease;
}
.route-filter-bar-summary:hover {
background: var(--color-bg);
background: color-mix(in srgb, var(--color-bg) 74%, white 26%);
}
.route-filter-bar-expanded {
padding: 8px 12px 12px;
padding: 8px 10px 10px;
border-top: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: 8px;
animation: filter-bar-slide-down 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.route-filter-bar-expanded.is-closing {
animation: filter-bar-slide-up 0.18s ease forwards;
}
@keyframes filter-bar-slide-down {
from {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
max-height: 600px;
transform: translateY(0);
}
}
@keyframes filter-bar-slide-up {
from {
opacity: 1;
max-height: 600px;
}
to {
opacity: 0;
max-height: 0;
overflow: hidden;
}
}
.route-filter-row {
display: flex;
align-items: flex-start;
gap: 8px;
gap: 7px;
}
.route-filter-row-label {
min-width: 40px;
font-size: 12px;
min-width: 46px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-secondary);
padding-top: 5px;
padding-top: 6px;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.route-filter-row-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 4px;
flex: 1;
min-width: 0;
}
@@ -5718,30 +5707,66 @@ textarea {
.filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-bg-card) 90%, white 10%);
cursor: pointer;
font-size: 12px;
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
transition: all 0.15s ease;
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease, box-shadow 0.18s ease, transform 0.18s ease;
line-height: 1.3;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.filter-chip:hover {
background: var(--color-bg);
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-bg) 78%, white 22%);
border-color: color-mix(in srgb, var(--color-text-muted) 22%, var(--color-border));
color: var(--color-text-primary);
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.05);
transform: translateY(-1px);
}
.filter-chip.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-info) 10%, var(--color-bg-card));
border-color: color-mix(in srgb, var(--color-info) 36%, var(--color-border));
color: var(--color-info);
font-weight: 500;
box-shadow: 0 10px 18px rgba(14, 165, 233, 0.08);
}
.route-filter-bar-summary-label {
font-weight: 600;
font-size: 12px;
color: var(--color-text-primary);
}
.route-filter-bar-summary-content {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-filter-bar-summary-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-card) 88%, white 12%);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 700;
}
.route-filter-bar-summary-count.has-active {
background: color-mix(in srgb, var(--color-info) 12%, white);
color: var(--color-info);
}
.filter-chip-icon {
@@ -5766,41 +5791,137 @@ textarea {
}
.filter-chip.active .filter-chip-count {
color: var(--color-primary);
color: var(--color-info);
}
/* ===== Route Card Grid (multi-column) ===== */
.route-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 12px;
gap: 10px;
align-items: start;
}
/* Collapsed route card */
.route-card-collapsed {
padding: 10px 14px;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
padding: 8px 10px;
border-radius: 14px;
border-color: color-mix(in srgb, var(--color-border) 84%, transparent);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, white 6%), color-mix(in srgb, var(--color-bg-card) 99%, white 1%));
box-shadow:
0 10px 20px rgba(15, 23, 42, 0.035),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
}
.route-card-collapsed.is-active {
border-color: color-mix(in srgb, var(--color-info) 36%, var(--color-border));
box-shadow:
0 12px 22px rgba(14, 165, 233, 0.07),
0 0 0 1px color-mix(in srgb, var(--color-info) 16%, transparent);
}
.route-card-collapsed:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: color-mix(in srgb, var(--color-info) 28%, var(--color-border));
box-shadow:
0 12px 22px rgba(15, 23, 42, 0.05),
0 0 0 1px color-mix(in srgb, var(--color-info) 10%, transparent);
transform: translateY(-1px);
}
/* Expanded route card spans full width */
.route-card-expanded {
grid-column: 1 / -1;
border-radius: 16px;
border-color: color-mix(in srgb, var(--color-border) 82%, transparent);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 95%, white 5%), color-mix(in srgb, var(--color-bg-card) 99%, white 1%));
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.04);
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.route-card-expanded-compact {
margin-top: -2px;
border-top-left-radius: 18px;
border-top-right-radius: 18px;
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary) 8%);
background: color-mix(in srgb, var(--color-bg-card) 96%, white 4%);
}
.route-card-detail-panel {
grid-column: 1 / -1;
margin-top: -1px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
contain: layout paint;
}
.route-detail-panel-presence {
overflow: clip;
contain: layout paint;
opacity: 1;
transform: none;
transition: grid-template-rows 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.route-detail-panel-presence.is-closing {
pointer-events: none;
}
.route-detail-panel-presence.is-open .route-card-detail-panel {
animation: route-detail-panel-in 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes route-detail-panel-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-theme="dark"] .route-batch-bar {
background: color-mix(in srgb, var(--color-bg-card) 92%, #0b1220 8%);
box-shadow: none;
}
[data-theme="dark"] .route-filter-bar {
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, #0b1220 6%), color-mix(in srgb, var(--color-bg-card) 99%, #020617 1%));
box-shadow: 0 16px 34px rgba(2, 6, 23, 0.22);
}
[data-theme="dark"] .filter-chip {
background: color-mix(in srgb, var(--color-bg-card) 82%, #0f172a 18%);
box-shadow: none;
}
[data-theme="dark"] .filter-chip:hover,
[data-theme="dark"] .filter-chip.active {
box-shadow: none;
}
[data-theme="dark"] .route-card-collapsed {
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, #0b1220 6%), color-mix(in srgb, var(--color-bg-card) 99%, #020617 1%));
box-shadow:
0 16px 30px rgba(2, 6, 23, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
[data-theme="dark"] .route-card-expanded {
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 95%, #0b1220 5%), color-mix(in srgb, var(--color-bg-card) 99%, #020617 1%));
box-shadow: 0 18px 34px rgba(2, 6, 23, 0.24);
}
@media (max-width: 768px) {
.route-batch-bar {
border-radius: 16px;
padding: 9px 10px;
}
.route-card-grid {
grid-template-columns: 1fr;
}
+174 -36
View File
@@ -1,4 +1,4 @@
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { DragEndEvent } from '@dnd-kit/core';
import { api } from '../api.js';
@@ -93,6 +93,7 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
sourceRouteIds: [],
advancedOpen: false,
};
const DESKTOP_DETAIL_COLLAPSE_MS = 180;
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
if (value === 'round_robin') return '已切换为轮询策略';
@@ -104,6 +105,61 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function DesktopDetailPanelPresence({
open,
children,
}: {
open: boolean;
children: (closing: boolean) => JSX.Element;
}) {
const [shouldRender, setShouldRender] = useState(open);
const [isOpen, setIsOpen] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const hasEverOpenedRef = useRef(open);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
setShouldRender(true);
setIsClosing(false);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsOpen(true));
return () => window.cancelAnimationFrame(rafId);
}
setIsOpen(true);
return undefined;
}
if (!hasEverOpenedRef.current) {
setShouldRender(false);
setIsOpen(false);
setIsClosing(false);
return undefined;
}
setIsOpen(false);
setIsClosing(true);
const timerId = globalThis.setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, DESKTOP_DETAIL_COLLAPSE_MS);
return () => globalThis.clearTimeout(timerId);
}, [open]);
if (!shouldRender) return null;
return (
<div
className={`route-detail-panel-presence anim-collapse route-panel-collapse ${isOpen ? 'is-open' : ''} ${isClosing ? 'is-closing' : ''}`.trim()}
style={{ gridColumn: '1 / -1' }}
>
<div className="anim-collapse-inner">
{children(isClosing)}
</div>
</div>
);
}
export default function TokenRoutes() {
const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -145,8 +201,10 @@ export default function TokenRoutes() {
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
const [closingDesktopDetailRouteIds, setClosingDesktopDetailRouteIds] = useState<number[]>([]);
const [addChannelModalRouteId, setAddChannelModalRouteId] = useState<number | null>(null);
const isMobile = useIsMobile();
const desktopDetailCloseTimersRef = useRef<Record<number, ReturnType<typeof globalThis.setTimeout>>>({});
const {
channelsByRouteId,
@@ -914,6 +972,14 @@ export default function TokenRoutes() {
return hints;
};
const missingTokenSiteItemsCacheRef = useRef<{ key: string; cache: Map<number, MissingTokenRouteSiteActionItem[]> }>({
key: '',
cache: new Map(),
});
if (missingTokenSiteItemsCacheRef.current.key !== missingTokenCacheKey) {
missingTokenSiteItemsCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
}
// Lazy per-route missing token group index
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}|${candidatesVersionRef.current}`;
@@ -933,6 +999,14 @@ export default function TokenRoutes() {
return hints;
};
const missingTokenGroupItemsCacheRef = useRef<{ key: string; cache: Map<number, MissingTokenGroupRouteSiteActionItem[]> }>({
key: '',
cache: new Map(),
});
if (missingTokenGroupItemsCacheRef.current.key !== missingTokenGroupCacheKey) {
missingTokenGroupItemsCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
}
const routeById = useMemo(
() => new Map(visibleRouteRows.map((route) => [route.id, route])),
[visibleRouteRows],
@@ -1207,8 +1281,25 @@ export default function TokenRoutes() {
const toggleExpand = async (routeId: number) => {
const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) {
if (!isMobile) {
setClosingDesktopDetailRouteIds((prev) => (prev.includes(routeId) ? prev : [...prev, routeId]));
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
if (existingTimer) {
globalThis.clearTimeout(existingTimer);
}
desktopDetailCloseTimersRef.current[routeId] = globalThis.setTimeout(() => {
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
delete desktopDetailCloseTimersRef.current[routeId];
}, DESKTOP_DETAIL_COLLAPSE_MS);
}
setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
} else {
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
if (existingTimer) {
globalThis.clearTimeout(existingTimer);
delete desktopDetailCloseTimersRef.current[routeId];
}
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
loadCandidates();
setExpandedRouteIds((prev) => [...prev, routeId]);
// Load channels on demand
@@ -1224,7 +1315,16 @@ export default function TokenRoutes() {
}
};
useEffect(() => () => {
Object.values(desktopDetailCloseTimersRef.current).forEach((timerId) => {
globalThis.clearTimeout(timerId);
});
desktopDetailCloseTimersRef.current = {};
}, []);
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
const cached = missingTokenSiteItemsCacheRef.current.cache.get(routeId);
if (cached) return cached;
const missingTokenHints = getRouteMissingTokenHints(routeId);
if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS;
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
@@ -1245,12 +1345,16 @@ export default function TokenRoutes() {
}
}
}
return Array.from(siteMap.values()).sort((a, b) => (
const items = Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
missingTokenSiteItemsCacheRef.current.cache.set(routeId, items);
return items;
};
const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => {
const cached = missingTokenGroupItemsCacheRef.current.cache.get(routeId);
if (cached) return cached;
const missingGroupHints = getRouteMissingTokenGroupHints(routeId);
if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS;
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
@@ -1292,9 +1396,11 @@ export default function TokenRoutes() {
}
}
}
return Array.from(siteMap.values()).sort((a, b) => (
const items = Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
missingTokenGroupItemsCacheRef.current.cache.set(routeId, items);
return items;
};
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
@@ -1386,7 +1492,7 @@ export default function TokenRoutes() {
return (
<div className="animate-fade-in" style={{ minHeight: 400 }}>
{/* Toolbar: search + sort + actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<div className="toolbar-search" style={{ minWidth: 220, flex: 1, maxWidth: 360 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@@ -1422,7 +1528,7 @@ export default function TokenRoutes() {
</div>
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 12px', fontSize: 12 }}
style={{ border: '1px solid var(--color-border)', padding: '7px 11px', fontSize: 12 }}
onClick={() => setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
data-tooltip={tr('切换排序方向')}
aria-label={tr('切换排序方向')}
@@ -1436,7 +1542,7 @@ export default function TokenRoutes() {
onClick={handleRefreshRouteDecisions}
disabled={loadingDecision}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px' }}
>
{loadingDecision ? (
<><span className="spinner spinner-sm" /> {tr('刷新中...')}</>
@@ -1449,7 +1555,7 @@ export default function TokenRoutes() {
onClick={handleRebuild}
disabled={rebuilding}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px' }}
>
{rebuilding ? (
<><span className="spinner spinner-sm" /> {tr('重建中...')}</>
@@ -1465,7 +1571,7 @@ export default function TokenRoutes() {
setShowManual(true);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px' }}
>
{tr('新建群组')}
</button>
@@ -1473,7 +1579,7 @@ export default function TokenRoutes() {
<button
onClick={toggleBatchSelectMode}
className={`btn ${batchSelectMode ? 'btn-primary' : 'btn-ghost'}`}
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px' }}
>
{batchSelectMode ? tr('退出批量') : tr('批量操作')}
</button>
@@ -1486,7 +1592,7 @@ export default function TokenRoutes() {
setShowZeroChannelRoutes((prev) => !prev);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px' }}
>
{showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')}
</button>
@@ -1507,7 +1613,7 @@ export default function TokenRoutes() {
mobileTrigger={(
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
style={{ border: '1px solid var(--color-border)', padding: '7px 12px', marginBottom: 8 }}
onClick={() => {
loadCandidates();
setShowFilters(true);
@@ -1565,16 +1671,6 @@ export default function TokenRoutes() {
)}
/>
{/* Info tip */}
<div className="info-tip" style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div>{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
<div>{`${getRouteRoutingStrategyLabel('weighted')}:${getRouteRoutingStrategyDescription('weighted')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('round_robin')}:${getRouteRoutingStrategyDescription('round_robin')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('stable_first')}:${getRouteRoutingStrategyDescription('stable_first')}`}</div>
<div>{tr('选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。')}</div>
<div>{tr('成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
</div>
{/* Manual route panel */}
<ManualRoutePanel
show={showManual}
@@ -1594,15 +1690,7 @@ export default function TokenRoutes() {
{/* Route card grid */}
{/* Batch selection floating bar */}
{batchSelectMode && (
<div style={{
position: 'sticky', top: 0, zIndex: 50,
background: 'var(--color-bg-card, #fff)',
border: '1px solid var(--color-border)',
borderRadius: 8, padding: '10px 16px',
marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}>
<div className="route-batch-bar">
<span style={{ fontSize: 13, fontWeight: 500 }}>
{tr('已选择')} <b>{selectedRouteIds.size}</b> / {selectableRouteIds.size} {tr('条路由')}
</span>
@@ -1632,6 +1720,7 @@ export default function TokenRoutes() {
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id);
const isDesktopDetailClosing = closingDesktopDetailRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route);
@@ -1758,12 +1847,51 @@ export default function TokenRoutes() {
);
}
const routeCard = (
const summaryCard = (
<RouteCard
key={route.id}
route={route}
brand={routeBrandById.get(route.id) || null}
expanded={isExpanded}
expanded={false}
summaryExpanded={isExpanded || isDesktopDetailClosing}
onToggleExpand={stableToggleExpand}
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
onClearCooldown={stableClearRouteCooldown}
clearingCooldown={!!clearingCooldownByRoute[route.id]}
onRoutingStrategyChange={stableRoutingStrategyChange}
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
channels={channelsByRouteId[route.id]}
loadingChannels={!!loadingChannelsByRouteId[route.id]}
routeDecision={decisionByRoute[route.id] || null}
loadingDecision={loadingDecision}
candidateView={EMPTY_ROUTE_CANDIDATE_VIEW}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
savingPriority={!!savingPriorityByRoute[route.id]}
onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel}
onToggleChannelEnabled={stableToggleChannelEnabled}
onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={EMPTY_MISSING_ITEMS}
missingTokenGroupItems={EMPTY_MISSING_GROUP_ITEMS}
onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel}
onSiteBlockModel={stableSiteBlockModel}
expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup}
/>
);
const detailPanel = (
<DesktopDetailPanelPresence open={isExpanded}>
{() => (
<RouteCard
route={route}
brand={routeBrandById.get(route.id) || null}
expanded
compact
detailPanel
onToggleExpand={stableToggleExpand}
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
@@ -1793,11 +1921,14 @@ export default function TokenRoutes() {
expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup}
/>
)}
</DesktopDetailPanelPresence>
);
if (batchSelectMode && isSelectable) {
return (
<div key={route.id} style={{ display: 'flex', gap: 0, alignItems: 'stretch', ...(isExpanded ? { gridColumn: '1 / -1' } : {}) }}>
<Fragment key={route.id}>
<div style={{ display: 'flex', gap: 0, alignItems: 'stretch' }}>
<div
onClick={() => toggleRouteSelection(route.id)}
style={{
@@ -1821,13 +1952,20 @@ export default function TokenRoutes() {
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{routeCard}
{summaryCard}
</div>
</div>
{detailPanel}
</Fragment>
);
}
return routeCard;
return (
<Fragment key={route.id}>
{summaryCard}
{detailPanel}
</Fragment>
);
})}
</div>
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
vi.mock('@dnd-kit/sortable', async () => {
const actual = await vi.importActual<typeof import('@dnd-kit/sortable')>('@dnd-kit/sortable');
return {
...actual,
useSortable: () => ({
attributes: { 'data-sortable-handle': 'true' },
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: { x: 0, y: 18, scaleX: 0.82, scaleY: 1.24 },
transition: 'transform 200ms ease',
isDragging: false,
}),
};
});
import RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function buildRoute(overrides: Partial<RouteSummaryRow> = {}): RouteSummaryRow {
return {
id: 42,
modelPattern: 'gpt-5.4',
displayName: 'gpt-5.4',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 3,
enabledChannelCount: 3,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
...overrides,
};
}
function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
return {
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.4',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
...overrides,
};
}
describe('RouteCard sortable shell', () => {
it('applies sortable transform to the outer channel shell instead of the inner row card', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 1 }),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const shell = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-shell'
&& node.props['data-channel-id'] === 11
));
const innerRow = shell.find((node) => (
node.type === 'div'
&& node.props.style
&& node.props.style.borderRadius === 14
&& collectText(node).includes('user_a')
));
expect(shell.props.style.transform).toContain('translate3d');
expect(shell.props.style.transform).not.toContain('scale');
expect(String(shell.props.style.transition || '')).toContain('transform');
expect(innerRow.props.style.transform).toBeUndefined();
});
});
+548 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { SortableContext } from '@dnd-kit/sortable';
import RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js';
import { getRouteRoutingStrategyDescription } from './routingStrategy.js';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
@@ -97,15 +100,74 @@ describe('RouteCard', () => {
));
expect(regexBadge.props.style).toMatchObject({
maxWidth: 180,
flex: '0 1 116px',
maxWidth: 116,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flexShrink: 1,
});
});
it('gives the collapsed group title more layout priority than the regex badge', () => {
const root = create(
<RouteCard
route={buildRoute({
displayName: 'minimax m2.1 群组名称',
})}
brand={null}
expanded={false}
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const titleRow = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'collapsed-route-title-row'
));
const titleNode = titleRow.find((node) => (
node.type === 'code'
&& collectText(node) === 'minimax m2.1 群组名称'
));
const regexBadge = titleRow.find((node) => (
node.type === 'span'
&& typeof node.props.className === 'string'
&& node.props.className.includes('badge-muted')
&& collectText(node) === LONG_REGEX_PATTERN
));
expect(titleNode.props.style.flex).toBe('1 1 180px');
expect(regexBadge.props.style.flex).toBe('0 1 116px');
expect(regexBadge.props.style.maxWidth).toBe(116);
});
it('renders a clear cooldown action on expanded cards', () => {
const onClearCooldown = vi.fn();
const root = create(
@@ -154,6 +216,60 @@ describe('RouteCard', () => {
expect(onClearCooldown).toHaveBeenCalledTimes(1);
});
it('lets keyboard users toggle the collapsed summary card', () => {
const onToggleExpand = vi.fn();
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded={false}
summaryExpanded={false}
onToggleExpand={onToggleExpand}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const summaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
expect(summaryCard.props.role).toBe('button');
expect(summaryCard.props.tabIndex).toBe(0);
expect(summaryCard.props['aria-expanded']).toBe(false);
summaryCard.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
summaryCard.props.onKeyDown({ key: ' ', preventDefault: vi.fn() });
expect(onToggleExpand).toHaveBeenCalledTimes(2);
});
it('renders desktop priority rail summaries for multiple channel layers', () => {
const root = create(
<RouteCard
@@ -229,4 +345,433 @@ describe('RouteCard', () => {
expect(p1RailNode.props.style.background).not.toBe('var(--color-bg)');
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color);
});
it('uses translate-only rect sorting for flat channel shell rows', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 1 }),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const sortableContext = root.root.findByType(SortableContext);
expect(sortableContext.props.strategy).toBe(translateOnlyRectSortingStrategy);
});
it('renders desktop channel rows in sortable shell order within a single sortable list', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[
buildChannel({ id: 11, priority: 0 }),
buildChannel({ id: 12, accountId: 102, tokenId: 1002, priority: 0 }),
buildChannel({ id: 21, accountId: 103, tokenId: 1003, priority: 1 }),
]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const sortableList = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-sortable-list'
));
const directShells = sortableList.findAll((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'route-channel-shell'
));
expect(directShells.map((child) => child.props['data-channel-id'])).toEqual([11, 12, 21]);
});
it('omits long explanatory copy in compact detail panels', () => {
const root = create(
<RouteCard
route={buildRoute({
modelPattern: 'gpt-4o-*',
displayName: 'gpt-4o',
})}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const detailText = collectText(root.root);
expect(detailText).not.toContain('通配符路由按请求实时决策');
expect(detailText).not.toContain(getRouteRoutingStrategyDescription('weighted'));
});
it('places compact route strategy and add channel controls on the same row', () => {
const root = create(
<RouteCard
route={buildRoute()}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const compactActionRow = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-action-row'
));
const strategySelectWrap = compactActionRow.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-strategy-select'
));
const addChannelButton = compactActionRow.find((node) => (
node.type === 'button'
&& collectText(node).includes('添加通道')
));
expect(compactActionRow.props.style.flexDirection).toBe('row');
expect(compactActionRow.props.style.justifyContent).toBe('flex-start');
expect(collectText(compactActionRow)).toContain('路由策略');
expect(collectText(compactActionRow)).toContain('添加通道');
expect(strategySelectWrap.props.style.flex).toBe('0 0 168px');
expect(addChannelButton.props.style.marginLeft).toBe('auto');
});
it('keeps compact status badges inline with the route name', () => {
const root = create(
<RouteCard
route={buildRoute({
modelPattern: 'gpt-5.2-codex',
channelCount: 16,
})}
brand={null}
expanded
compact
detailPanel
onToggleExpand={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
onClearCooldown={vi.fn()}
clearingCooldown={false}
onRoutingStrategyChange={vi.fn()}
updatingRoutingStrategy={false}
channels={[buildChannel()]}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleChannelEnabled={vi.fn()}
onChannelDragEnd={vi.fn()}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={vi.fn()}
onAddChannel={vi.fn()}
onSiteBlockModel={vi.fn()}
expandedSourceGroupMap={{}}
onToggleSourceGroup={vi.fn()}
/>,
);
const compactHeaderMain = root.root.find((node) => (
node.type === 'div'
&& node.props['data-testid'] === 'compact-route-header-main'
));
expect(compactHeaderMain.props.style.flexDirection).toBe('row');
expect(collectText(compactHeaderMain)).toContain('gpt-5.2-codex');
expect(collectText(compactHeaderMain)).toContain('启用');
expect(collectText(compactHeaderMain)).toContain('16 通道');
});
it('skips collapsed rerenders when only expanded-channel state changes', () => {
const routeTarget = buildRoute();
let modelPatternReadCount = 0;
const route = new Proxy(routeTarget, {
get(target, property, receiver) {
if (property === 'modelPattern') {
modelPatternReadCount += 1;
}
return Reflect.get(target, property, receiver);
},
}) as RouteSummaryRow;
const callbacks = {
onToggleExpand: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onToggleEnabled: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const candidateView = { routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} };
const renderCard = (channelTokenDraft: Record<number, number>, updatingChannel: Record<number, boolean>) => (
<RouteCard
route={route}
brand={null}
expanded={false}
onToggleExpand={callbacks.onToggleExpand}
onEdit={callbacks.onEdit}
onDelete={callbacks.onDelete}
onToggleEnabled={callbacks.onToggleEnabled}
onClearCooldown={callbacks.onClearCooldown}
clearingCooldown={false}
onRoutingStrategyChange={callbacks.onRoutingStrategyChange}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={candidateView}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
savingPriority={false}
onTokenDraftChange={callbacks.onTokenDraftChange}
onSaveToken={callbacks.onSaveToken}
onDeleteChannel={callbacks.onDeleteChannel}
onToggleChannelEnabled={callbacks.onToggleChannelEnabled}
onChannelDragEnd={callbacks.onChannelDragEnd}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={callbacks.onCreateTokenForMissing}
onAddChannel={callbacks.onAddChannel}
onSiteBlockModel={callbacks.onSiteBlockModel}
expandedSourceGroupMap={{}}
onToggleSourceGroup={callbacks.onToggleSourceGroup}
/>
);
let root!: WebTestRenderer;
act(() => {
root = create(renderCard({}, {}));
});
const initialReadCount = modelPatternReadCount;
act(() => {
root.update(renderCard({ 11: 1001 }, { 11: true }));
});
expect(modelPatternReadCount).toBe(initialReadCount);
});
it('skips collapsed rerenders when only expanded-only callback identities change', () => {
const routeTarget = buildRoute();
let modelPatternReadCount = 0;
const route = new Proxy(routeTarget, {
get(target, property, receiver) {
if (property === 'modelPattern') {
modelPatternReadCount += 1;
}
return Reflect.get(target, property, receiver);
},
}) as RouteSummaryRow;
const callbacksA = {
onToggleExpand: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onToggleEnabled: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const callbacksB = {
...callbacksA,
onEdit: vi.fn(),
onDelete: vi.fn(),
onClearCooldown: vi.fn(),
onRoutingStrategyChange: vi.fn(),
onTokenDraftChange: vi.fn(),
onSaveToken: vi.fn(),
onDeleteChannel: vi.fn(),
onToggleChannelEnabled: vi.fn(),
onChannelDragEnd: vi.fn(),
onCreateTokenForMissing: vi.fn(),
onAddChannel: vi.fn(),
onSiteBlockModel: vi.fn(),
onToggleSourceGroup: vi.fn(),
};
const candidateView = { routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} };
const renderCard = (callbacks: typeof callbacksA) => (
<RouteCard
route={route}
brand={null}
expanded={false}
onToggleExpand={callbacks.onToggleExpand}
onEdit={callbacks.onEdit}
onDelete={callbacks.onDelete}
onToggleEnabled={callbacks.onToggleEnabled}
onClearCooldown={callbacks.onClearCooldown}
clearingCooldown={false}
onRoutingStrategyChange={callbacks.onRoutingStrategyChange}
updatingRoutingStrategy={false}
channels={undefined}
loadingChannels={false}
routeDecision={null}
loadingDecision={false}
candidateView={candidateView}
channelTokenDraft={{}}
updatingChannel={{}}
savingPriority={false}
onTokenDraftChange={callbacks.onTokenDraftChange}
onSaveToken={callbacks.onSaveToken}
onDeleteChannel={callbacks.onDeleteChannel}
onToggleChannelEnabled={callbacks.onToggleChannelEnabled}
onChannelDragEnd={callbacks.onChannelDragEnd}
missingTokenSiteItems={[]}
missingTokenGroupItems={[]}
onCreateTokenForMissing={callbacks.onCreateTokenForMissing}
onAddChannel={callbacks.onAddChannel}
onSiteBlockModel={callbacks.onSiteBlockModel}
expandedSourceGroupMap={{}}
onToggleSourceGroup={callbacks.onToggleSourceGroup}
/>
);
const root = create(renderCard(callbacksA));
const initialReadCount = modelPatternReadCount;
act(() => {
root.update(renderCard(callbacksB));
});
expect(modelPatternReadCount).toBe(initialReadCount);
});
});
+496 -202
View File
@@ -1,11 +1,10 @@
import { memo, useState, type ReactNode } from 'react';
import { Fragment, memo, useState, type CSSProperties } from 'react';
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
useDroppable,
useSensor,
@@ -13,12 +12,12 @@ import {
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
import type {
@@ -53,12 +52,15 @@ import {
createPriorityRailNewLayerId,
isPriorityRailNewLayerId,
} from './priorityRail.js';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
type RouteCardProps = {
route: RouteSummaryRow;
brand: BrandInfo | null;
expanded: boolean;
compact?: boolean;
summaryExpanded?: boolean;
detailPanel?: boolean;
onToggleExpand: (routeId: number) => void;
onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void;
@@ -96,18 +98,6 @@ type RouteCardProps = {
onToggleSourceGroup: (groupKey: string) => void;
};
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
const presence = useAnimatedVisibility(open, 220);
if (!presence.shouldRender) return null;
return (
<div className={`anim-collapse ${presence.isVisible ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{children}
</div>
</div>
);
}
function PriorityRailNewLayerRow({
id,
highlighted,
@@ -142,7 +132,7 @@ function PriorityRailNewLayerRow({
fontWeight: 600,
textAlign: 'center',
lineHeight: 1.2,
transition: 'all 0.16s ease',
transition: 'border-color 0.16s ease, background 0.16s ease, color 0.16s ease',
}}
>
{tr('放到新档位')}
@@ -152,18 +142,218 @@ function PriorityRailNewLayerRow({
height: 0,
borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
opacity: active ? 1 : 0.75,
transition: 'all 0.16s ease',
transition: 'border-color 0.16s ease, opacity 0.16s ease',
}}
/>
</div>
);
}
type SortableChannelShellProps = {
channel: RouteChannel;
bucketIndex: number;
channelIndex: number;
bucketChannelCount: number;
totalBucketCount: number;
compact: boolean;
readOnlyRoute: boolean;
savingPriority: boolean;
candidateView: RouteCandidateView;
channelTokenDraft: Record<number, number>;
updatingChannel: Record<number, boolean>;
activeDragChannelId: number | null;
decisionMap: Map<number, RouteDecisionCandidate>;
exactRoute: boolean;
loadingDecision: boolean;
channelManagementDisabled: boolean;
routeId: number;
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;
onSiteBlockModel: (channelId: number, routeId: number) => void;
railLabel: string;
mobileRailLabel: string;
railNodeStyle: CSSProperties;
};
function SortableChannelShell({
channel,
bucketIndex,
channelIndex,
bucketChannelCount,
totalBucketCount,
compact,
readOnlyRoute,
savingPriority,
candidateView,
channelTokenDraft,
updatingChannel,
activeDragChannelId,
decisionMap,
exactRoute,
loadingDecision,
channelManagementDisabled,
routeId,
onTokenDraftChange,
onSaveToken,
onDeleteChannel,
onToggleChannelEnabled,
onSiteBlockModel,
railLabel,
mobileRailLabel,
railNodeStyle,
}: SortableChannelShellProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: channel.id,
disabled: savingPriority || readOnlyRoute,
});
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
const showDesktopRailHeader = !compact && channelIndex === 0;
const showDesktopRailLine = !compact
&& (bucketIndex < totalBucketCount - 1 || channelIndex < bucketChannelCount - 1);
const shellTransition = [
transition,
'opacity 180ms ease',
].filter(Boolean).join(', ');
const translatedTransform = transform
? { ...transform, scaleX: 1, scaleY: 1 }
: null;
return (
<div
ref={setNodeRef}
data-testid="route-channel-shell"
data-channel-id={channel.id}
style={{
transform: CSS.Translate.toString(translatedTransform),
transition: shellTransition || undefined,
zIndex: isDragging ? 10 : undefined,
willChange: isDragging || Boolean(transform) || Boolean(transition) ? 'transform' : undefined,
display: compact ? 'flex' : 'grid',
flexDirection: compact ? 'column' : undefined,
gridTemplateColumns: compact ? undefined : '86px minmax(0, 1fr)',
gap: compact ? 6 : 12,
alignItems: 'stretch',
}}
>
{compact && channelIndex === 0 ? (
<div
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
padding: '0 2px',
fontSize: 11,
color: 'var(--color-text-secondary)',
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-secondary)',
}}
>
{mobileRailLabel}
</span>
</div>
) : null}
{!compact ? (
<div
aria-hidden
style={{
width: 86,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: showDesktopRailHeader ? 2 : 0,
}}
>
{showDesktopRailHeader ? (
<>
<div
style={{
minWidth: 64,
padding: '5px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
textAlign: 'center',
lineHeight: 1.2,
transition: 'border-color 0.16s ease, background 0.16s ease, color 0.16s ease',
...railNodeStyle,
}}
>
{railLabel}
</div>
</>
) : (
<div style={{ minWidth: 64 }} />
)}
{showDesktopRailLine ? (
<div
style={{
width: 1,
flex: 1,
minHeight: showDesktopRailHeader ? 10 : 0,
marginTop: showDesktopRailHeader ? 6 : 0,
background: 'var(--color-border)',
}}
/>
) : null}
</div>
) : null}
<SortableChannelRow
channel={channel}
displayPriority={bucketIndex}
showPriorityBadge={compact}
dragging={isDragging}
dragHandleProps={{ ...attributes, ...listeners }}
dragHandleRef={setActivatorNodeRef}
dragInProgress={activeDragChannelId != null}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
readOnly={readOnlyRoute}
channelManagementDisabled={channelManagementDisabled}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(routeId, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, routeId)}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, routeId, enabled)}
onSiteBlockModel={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, routeId)}
/>
</div>
);
}
function RouteCardInner({
route,
brand,
expanded,
compact = false,
summaryExpanded = false,
detailPanel = false,
onToggleExpand,
onEdit,
onDelete,
@@ -207,6 +397,8 @@ function RouteCardInner({
const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
: undefined;
const showAddChannelButton = !readOnlyRoute && !channelManagementDisabled;
const showMissingTokenHints = !channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0);
const routingStrategyOptions = [
{
value: 'weighted',
@@ -237,30 +429,14 @@ function RouteCardInner({
const priorityBuckets = buildPriorityBuckets(channels || []);
const priorityRailSections = buildPriorityRailSections(channels || []);
const [activeDragChannelId, setActiveDragChannelId] = useState<number | null>(null);
const [dragOverId, setDragOverId] = useState<string | number | null>(null);
const activeDragBucketIndex = activeDragChannelId == null
? -1
: priorityBuckets.findIndex((bucket) => bucket.channels.some((channel) => channel.id === activeDragChannelId));
const hoveredBucketIndex = typeof dragOverId === 'number'
? priorityBuckets.findIndex((bucket) => bucket.channels.some((channel) => channel.id === dragOverId))
: -1;
const hoveredNewLayerBucketIndex = typeof dragOverId === 'string' && isPriorityRailNewLayerId(dragOverId)
? priorityBuckets.findIndex((bucket) => createPriorityRailNewLayerId(bucket.priority) === dragOverId)
: -1;
const clearDragState = () => {
setActiveDragChannelId(null);
setDragOverId(null);
};
const handleDragStart = (event: DragStartEvent) => {
const nextId = Number(event.active.id);
setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null);
setDragOverId(event.active.id);
};
const handleDragOver = (event: DragOverEvent) => {
setDragOverId(event.over?.id ?? null);
};
const handleDragEnd = (event: DragEndEvent) => {
@@ -275,13 +451,47 @@ function RouteCardInner({
</button>
);
};
const renderAddChannelButton = ({
fullWidth = false,
alignRight = false,
}: {
fullWidth?: boolean;
alignRight?: boolean;
} = {}) => (
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{
fontSize: 11.5,
padding: '5px 10px',
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-bg-card) 96%, white 4%)',
border: '1px dashed color-mix(in srgb, var(--color-border) 88%, transparent)',
borderRadius: 12,
whiteSpace: fullWidth ? 'normal' : 'nowrap',
width: fullWidth ? '100%' : 'auto',
marginLeft: alignRight ? 'auto' : undefined,
}}
>
+ {tr('添加通道')}
</button>
);
// Collapsed card
if (!expanded) {
return (
<div
className="card route-card-collapsed"
className={`card route-card-collapsed ${summaryExpanded ? 'is-active' : ''}`.trim()}
onClick={() => onToggleExpand(route.id)}
role="button"
tabIndex={0}
aria-expanded={summaryExpanded}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleExpand(route.id);
}
}}
style={{ cursor: 'pointer' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
@@ -297,7 +507,22 @@ function RouteCardInner({
) : null}
</span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
<div
data-testid="collapsed-route-title-row"
style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0, flex: '1 1 180px' }}
>
<code
style={{
fontWeight: 600,
fontSize: 13,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: '1 1 180px',
}}
>
{title}
</code>
@@ -307,9 +532,9 @@ function RouteCardInner({
title={route.modelPattern}
style={{
fontSize: 10,
flexShrink: 1,
flex: '0 1 116px',
minWidth: 0,
maxWidth: 180,
maxWidth: 116,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -318,6 +543,7 @@ function RouteCardInner({
{route.modelPattern}
</span>
) : null}
</div>
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
@@ -374,7 +600,12 @@ function RouteCardInner({
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
style={{
flexShrink: 0,
color: 'var(--color-text-muted)',
transform: summaryExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s ease',
}}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
@@ -386,7 +617,10 @@ function RouteCardInner({
// Expanded card
return (
<div className={`card route-card-expanded ${compact ? 'route-card-expanded-compact' : ''}`.trim()} style={{ padding: compact ? 14 : 16 }}>
<div
className={`card route-card-expanded ${compact ? 'route-card-expanded-compact' : ''} ${detailPanel ? 'route-card-detail-panel' : ''}`.trim()}
style={{ padding: compact ? 10 : 14 }}
>
{!compact ? (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -477,28 +711,35 @@ function RouteCardInner({
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{tr('路由详情与通道管理')}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div
data-testid="compact-route-header-main"
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 5, flexWrap: 'wrap', minWidth: 0, flex: 1 }}
>
<code
style={{
fontWeight: 600,
fontSize: 12.5,
background: 'var(--color-bg)',
padding: '3px 8px',
borderRadius: 6,
color: 'var(--color-text-primary)',
display: 'inline-flex',
alignItems: 'center',
gap: 5,
minWidth: 0,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{title}
</div>
</div>
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{renderClearCooldownButton()}
{!exactRoute && (
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
)}
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null}
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('未生成')}</span>
) : (
@@ -525,25 +766,98 @@ function RouteCardInner({
) : null}
{savingPriority ? <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> : null}
</div>
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{renderClearCooldownButton()}
{(explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
{detailPanel && (
<button
onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost"
style={{ padding: '3px 8px', border: '1px solid var(--color-border)' }}
>
{tr('收起详情')}
</button>
)}
</div>
)}
</div>
</div>
)}
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{!compact && explicitGroupRoute ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
) : !compact && !exactRoute ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')}
</div>
) : null}
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'center', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', minWidth: compact ? '100%' : undefined }}>
<div
data-testid={compact ? 'compact-route-action-row' : undefined}
style={{
display: 'flex',
alignItems: compact ? 'center' : 'center',
flexDirection: compact ? 'row' : 'row',
justifyContent: compact ? 'flex-start' : 'space-between',
gap: compact ? 6 : 8,
marginBottom: 8,
flexWrap: 'wrap',
}}
>
{compact ? (
<>
<div
style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}
data-tooltip={`${routingStrategyDescription} ${routingStrategyHint}`}
>
<div
style={{ fontSize: 11.5, color: 'var(--color-text-secondary)', flexShrink: 0 }}
>
{tr('路由策略')}
</div>
<div style={{ minWidth: compact ? '100%' : 220, maxWidth: compact ? '100%' : 320, flex: compact ? '1 1 100%' : '1 1 220px', width: compact ? '100%' : undefined }}>
<div
data-testid="compact-route-strategy-select"
style={{
flex: '0 0 168px',
minWidth: 168,
maxWidth: 168,
}}
>
<ModernSelect
size="sm"
value={routingStrategy}
disabled={updatingRoutingStrategy}
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
options={routingStrategyOptions.map((option) => ({ value: option.value, label: option.label }))}
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
</div>
</div>
{showAddChannelButton ? renderAddChannelButton({ alignRight: true }) : null}
</>
) : (
<>
<div
style={{ fontSize: 11.5, color: 'var(--color-text-secondary)', minWidth: undefined }}
data-tooltip={undefined}
>
{tr('路由策略')}
</div>
<div
style={{
minWidth: 220,
maxWidth: 320,
flex: '1 1 220px',
}}
>
<ModernSelect
size="sm"
value={routingStrategy}
@@ -553,25 +867,27 @@ function RouteCardInner({
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, lineHeight: 1.55, color: 'var(--color-text-secondary)' }}>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ fontSize: 11.5, lineHeight: 1.45, color: 'var(--color-text-secondary)' }}>
{routingStrategyDescription}
</div>
<div style={{ fontSize: 11, lineHeight: 1.5, color: 'var(--color-text-muted)' }}>
<div style={{ fontSize: 10.5, lineHeight: 1.4, color: 'var(--color-text-muted)' }}>
{routingStrategyHint}
</div>
</div>
</div>
</>
)}
</div>
)}
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'flex-start', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'flex-start', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
{showMissingTokenHints ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1 }}>
{missingTokenSiteItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
{missingTokenSiteItems.map((item) => (
<button
key={`missing-${route.id}-${item.key}`}
@@ -579,7 +895,7 @@ function RouteCardInner({
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{ fontSize: 11, cursor: 'pointer' }}
style={{ fontSize: 10.5, cursor: 'pointer' }}
>
{item.siteName}
</button>
@@ -587,8 +903,8 @@ function RouteCardInner({
</div>
)}
{missingTokenGroupItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
{missingTokenGroupItems.map((item) => (
<button
key={`missing-group-${route.id}-${item.key}`}
@@ -596,7 +912,7 @@ function RouteCardInner({
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-warning missing-token-group-tag"
data-tooltip={`缺少分组:${item.missingGroups.join('、') || '未知'}${item.availableGroups.length > 0 ? `;已覆盖:${item.availableGroups.join('、')}` : ''}${item.groupCoverageUncertain ? ';当前分组覆盖存在不确定性' : ''}`}
style={{ fontSize: 11, cursor: 'pointer' }}
style={{ fontSize: 10.5, cursor: 'pointer' }}
>
{item.siteName}
</button>
@@ -604,162 +920,79 @@ function RouteCardInner({
</div>
)}
</div>
) : <div />}
{!readOnlyRoute && !channelManagementDisabled && (
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: compact ? 'normal' : 'nowrap', width: compact ? '100%' : 'auto' }}
>
+ {tr('添加通道')}
</button>
)}
) : (!compact && showAddChannelButton ? <div /> : null)}
{!compact && showAddChannelButton ? renderAddChannelButton() : null}
</div>
{/* Channel list */}
{loadingChannels ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0' }}>
<span className="spinner spinner-sm" />
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{tr('加载通道中...')}</span>
</div>
) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
<SortableContext items={(channels || []).map((channel) => channel.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 10 : 12 }}>
<SortableContext items={(channels || []).map((channel) => channel.id)} strategy={translateOnlyRectSortingStrategy}>
<div
data-testid="route-channel-sortable-list"
style={{ display: 'flex', flexDirection: 'column', gap: compact ? 8 : 4 }}
>
{priorityBuckets.map((bucket, bucketIndex) => {
const railSection = priorityRailSections[bucketIndex];
const railLabel = `P${bucketIndex} · ${bucket.channels.length}`;
const mobileRailLabel = `${railLabel} ${tr('通道')}`;
const hoveredExistingLayer = activeDragChannelId != null && hoveredBucketIndex === bucketIndex;
const hoveredCrossLayer = hoveredExistingLayer && activeDragBucketIndex !== bucketIndex;
const hoveredNewLayer = activeDragChannelId != null && hoveredNewLayerBucketIndex === bucketIndex;
const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, hoveredExistingLayer);
const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, false);
const showNewLayerTarget = !compact && activeDragChannelId != null && !readOnlyRoute;
return (
<div
key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}
style={{
display: 'flex',
flexDirection: 'column',
gap: compact ? 8 : 10,
}}
>
<div
style={{
display: compact ? 'flex' : 'grid',
flexDirection: compact ? 'column' : undefined,
gridTemplateColumns: compact ? undefined : '86px minmax(0, 1fr)',
gap: compact ? 8 : 12,
alignItems: compact ? 'stretch' : 'stretch',
}}
>
{compact ? (
<div
className="route-priority-bucket-header"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '2px 2px 0',
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--color-text-secondary)',
}}
>
{mobileRailLabel}
</span>
</div>
) : (
<div
aria-hidden
style={{
width: 86,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: 4,
}}
>
<div
style={{
minWidth: 72,
padding: '6px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
textAlign: 'center',
lineHeight: 1.2,
transition: 'all 0.16s ease',
...railNodeStyle,
}}
>
{railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
</div>
{hoveredCrossLayer ? (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--color-primary)', textAlign: 'center' }}>
{`放到 P${bucketIndex}`}
</div>
) : null}
{bucketIndex < priorityBuckets.length - 1 ? (
<div style={{ width: 1, flex: 1, marginTop: 8, background: 'var(--color-border)' }} />
) : null}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
{bucket.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
<Fragment key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}>
{bucket.channels.map((channel, channelIndex) => {
return (
<SortableChannelRow
<SortableChannelShell
key={channel.id}
channel={channel}
displayPriority={bucketIndex}
showPriorityBadge={compact}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
bucketIndex={bucketIndex}
channelIndex={channelIndex}
bucketChannelCount={bucket.channels.length}
totalBucketCount={priorityBuckets.length}
compact={compact}
readOnlyRoute={readOnlyRoute}
savingPriority={savingPriority}
candidateView={candidateView}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
activeDragChannelId={activeDragChannelId}
decisionMap={decisionMap}
exactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
readOnly={readOnlyRoute}
channelManagementDisabled={channelManagementDisabled}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
routeId={route.id}
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={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, route.id)}
onSaveToken={onSaveToken}
onDeleteChannel={onDeleteChannel}
onToggleChannelEnabled={onToggleChannelEnabled}
onSiteBlockModel={onSiteBlockModel}
railLabel={railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
mobileRailLabel={mobileRailLabel}
railNodeStyle={railNodeStyle}
/>
);
})}
</div>
</div>
{!compact && activeDragChannelId != null && !readOnlyRoute ? (
{showNewLayerTarget ? (
<PriorityRailNewLayerRow
id={createPriorityRailNewLayerId(bucket.priority)}
highlighted={hoveredNewLayer}
highlighted={false}
/>
) : null}
</div>
</Fragment>
);
})}
</div>
@@ -775,5 +1008,66 @@ function RouteCardInner({
);
}
const RouteCard = memo(RouteCardInner);
function buildChannelInteractionSignature(
channels: RouteChannel[] | undefined,
channelTokenDraft: Record<number, number>,
updatingChannel: Record<number, boolean>,
): string {
if (!Array.isArray(channels) || channels.length === 0) return '';
return channels
.map((channel) => `${channel.id}:${channelTokenDraft[channel.id] ?? ''}:${updatingChannel[channel.id] ? 1 : 0}`)
.join('|');
}
function areRouteCardPropsEqual(prev: RouteCardProps, next: RouteCardProps): boolean {
if (
prev.route !== next.route
|| prev.brand !== next.brand
|| prev.expanded !== next.expanded
|| prev.compact !== next.compact
|| prev.summaryExpanded !== next.summaryExpanded
|| prev.detailPanel !== next.detailPanel
|| prev.onToggleExpand !== next.onToggleExpand
|| prev.onToggleEnabled !== next.onToggleEnabled
) {
return false;
}
if (!next.expanded) {
return true;
}
if (
prev.onEdit !== next.onEdit
|| prev.onDelete !== next.onDelete
|| prev.onClearCooldown !== next.onClearCooldown
|| prev.onRoutingStrategyChange !== next.onRoutingStrategyChange
|| prev.onTokenDraftChange !== next.onTokenDraftChange
|| prev.onSaveToken !== next.onSaveToken
|| prev.onDeleteChannel !== next.onDeleteChannel
|| prev.onToggleChannelEnabled !== next.onToggleChannelEnabled
|| prev.onChannelDragEnd !== next.onChannelDragEnd
|| prev.onCreateTokenForMissing !== next.onCreateTokenForMissing
|| prev.onAddChannel !== next.onAddChannel
|| prev.onSiteBlockModel !== next.onSiteBlockModel
|| prev.onToggleSourceGroup !== next.onToggleSourceGroup
|| prev.clearingCooldown !== next.clearingCooldown
|| prev.updatingRoutingStrategy !== next.updatingRoutingStrategy
|| prev.savingPriority !== next.savingPriority
|| prev.loadingChannels !== next.loadingChannels
|| prev.loadingDecision !== next.loadingDecision
|| prev.routeDecision !== next.routeDecision
|| prev.candidateView !== next.candidateView
|| prev.missingTokenSiteItems !== next.missingTokenSiteItems
|| prev.missingTokenGroupItems !== next.missingTokenGroupItems
|| prev.channels !== next.channels
) {
return false;
}
return buildChannelInteractionSignature(prev.channels, prev.channelTokenDraft, prev.updatingChannel)
=== buildChannelInteractionSignature(next.channels, next.channelTokenDraft, next.updatingChannel);
}
const RouteCard = memo(RouteCardInner, areRouteCardPropsEqual);
export default RouteCard;
@@ -0,0 +1,132 @@
import { act, create } from 'react-test-renderer';
import { afterEach, describe, expect, it, vi } from 'vitest';
import RouteFilterBar from './RouteFilterBar.js';
function renderBar(collapsed: boolean) {
return (
<RouteFilterBar
totalRouteCount={3}
activeBrand={null}
setActiveBrand={vi.fn()}
activeSite={null}
setActiveSite={vi.fn()}
activeEndpointType={null}
setActiveEndpointType={vi.fn()}
activeGroupFilter={null}
setActiveGroupFilter={vi.fn()}
enabledFilter="all"
setEnabledFilter={vi.fn()}
enabledCounts={{ enabled: 2, disabled: 1 }}
brandList={{ list: [], otherCount: 0 }}
siteList={[]}
endpointTypeList={[]}
groupRouteList={[]}
collapsed={collapsed}
onToggle={vi.fn()}
/>
);
}
describe('RouteFilterBar', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('uses the shared collapse presence wrapper while expanded', () => {
const root = create(renderBar(false));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedPanel = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(String(presence.props.className)).toContain('anim-collapse');
expect(String(presence.props.className)).toContain('is-open');
expect(String(expandedPanel.props.className)).not.toContain('is-closing');
});
it('keeps the collapse wrapper mounted while collapsed so expand can animate from existing layout', () => {
const root = create(renderBar(true));
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
});
it('keeps expanded controls mounted briefly while closing, then removes them after the collapse window', () => {
vi.useFakeTimers();
const root = create(renderBar(false));
act(() => {
root.update(renderBar(true));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(presence.props.className)).not.toContain('is-open');
const expandedWhileClosing = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedWhileClosing).toHaveLength(1);
act(() => {
vi.advanceTimersByTime(181);
});
const expandedAfterWindow = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedAfterWindow).toHaveLength(0);
});
it('mounts expanded controls before flipping the presence wrapper open so expand does not start from an empty shell', () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal('window', {
requestAnimationFrame: (callback: FrameRequestCallback) => {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
cancelAnimationFrame: vi.fn(),
});
const root = create(renderBar(true));
act(() => {
root.update(renderBar(false));
});
const presence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
const expandedBeforeOpen = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-expanded')
));
expect(expandedBeforeOpen).toHaveLength(1);
expect(String(presence.props.className)).not.toContain('is-open');
expect(rafCallbacks).toHaveLength(1);
act(() => {
rafCallbacks[0](0);
});
const openPresence = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-filter-bar-presence')
));
expect(String(openPresence.props.className)).toContain('is-open');
});
});
+33 -5
View File
@@ -1,12 +1,13 @@
import type { ReactNode } from 'react';
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react';
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled';
const FILTER_EXPANDED_CONTENT_UNMOUNT_MS = 180;
type RouteFilterBarProps = {
totalRouteCount: number;
activeBrand: string | null;
@@ -104,8 +105,31 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
collapsed,
onToggle,
} = props;
const [renderExpandedContent, setRenderExpandedContent] = useState(!collapsed);
const [presenceOpen, setPresenceOpen] = useState(!collapsed);
const expandPresence = useAnimatedVisibility(!collapsed, 220);
useEffect(() => {
if (!collapsed) return undefined;
const timerId = globalThis.setTimeout(() => setRenderExpandedContent(false), FILTER_EXPANDED_CONTENT_UNMOUNT_MS);
return () => globalThis.clearTimeout(timerId);
}, [collapsed]);
useLayoutEffect(() => {
if (collapsed) {
setPresenceOpen(false);
return undefined;
}
setRenderExpandedContent(true);
setPresenceOpen(false);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setPresenceOpen(true));
return () => window.cancelAnimationFrame(rafId);
}
setPresenceOpen(true);
return undefined;
}, [collapsed]);
return (
<div className="route-filter-bar">
@@ -133,8 +157,10 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
</button>
{/* Expanded panel */}
{expandPresence.shouldRender && (
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div className={`anim-collapse route-filter-bar-presence ${presenceOpen ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{renderExpandedContent && (
<div className="route-filter-bar-expanded">
{/* Status row */}
<FilterRow label={tr('状态')}>
<FilterChip
@@ -296,5 +322,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
</div>
)}
</div>
</div>
</div>
);
}
@@ -68,10 +68,85 @@ describe('SortableChannelRow layering', () => {
const row = root.root.find((node) => (
node.type === 'div'
&& node.props.style
&& node.props.style.borderLeft === '2px solid var(--color-primary)'
&& node.props['data-layer-root']
));
expect(row.props.style.zIndex).toBeUndefined();
expect(row.props.style.borderLeft).toBeUndefined();
});
it('disables row tooltips while a drag interaction is in progress', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
dragInProgress
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const tooltipNodes = root.root.findAll((node) => node.props['data-tooltip'] !== undefined);
expect(tooltipNodes).toHaveLength(0);
});
it('treats channel-management-disabled rows as non-interactive for the drag handle', () => {
const channel = buildChannel();
const root = create(
<DndContext>
<SortableContext items={[channel.id]} strategy={verticalListSortingStrategy}>
<SortableChannelRow
channel={channel}
channelManagementDisabled
decisionCandidate={undefined}
isExactRoute
loadingDecision={false}
isSavingPriority={false}
tokenOptions={[
{
id: 501,
name: 'shared-token',
isDefault: true,
},
]}
activeTokenId={0}
isUpdatingToken={false}
onTokenDraftChange={vi.fn()}
onSaveToken={vi.fn()}
onDeleteChannel={vi.fn()}
onToggleEnabled={vi.fn()}
onSiteBlockModel={vi.fn()}
/>
</SortableContext>
</DndContext>,
);
const dragHandle = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '拖拽调整优先级桶'
));
expect(dragHandle.props.disabled).toBe(true);
expect(dragHandle.props['data-tooltip']).toBe('该路由当前不可编辑优先级');
});
});
+87 -153
View File
@@ -1,6 +1,4 @@
import { useState, type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js';
import {
@@ -15,12 +13,16 @@ export function SortableChannelRow({
channel,
displayPriority,
showPriorityBadge = true,
dragging = false,
dragHandleProps,
dragHandleRef,
decisionCandidate,
isExactRoute,
loadingDecision,
isSavingPriority,
readOnly = false,
channelManagementDisabled = false,
dragInProgress = false,
mobile = false,
tokenOptions,
activeTokenId,
@@ -31,37 +33,48 @@ export function SortableChannelRow({
onToggleEnabled,
onSiteBlockModel,
}: SortableChannelRowProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: channel.id,
disabled: isSavingPriority || readOnly,
});
const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled;
const suppressTooltips = dragInProgress || dragging;
const rowTransition = [
'box-shadow 180ms ease',
'background-color 180ms ease',
'border-color 180ms ease',
'opacity 180ms ease',
].filter(Boolean).join(', ');
const dragHandleStyle: CSSProperties = {
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 34%, var(--color-border-light))' : 'var(--color-border-light)'}`,
borderRadius: 10,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 80%, var(--color-info) 20%)'
: 'color-mix(in srgb, var(--color-bg-card) 90%, white 10%)',
boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.62)',
color: dragging ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
cursor: isSavingPriority || managementLocked ? 'not-allowed' : 'grab',
opacity: managementLocked ? 0.65 : 1,
transition: 'background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease',
};
const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
transition: rowTransition || undefined,
opacity: dragging ? 0.92 : channel.enabled === false ? 0.56 : 1,
display: 'grid',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: 8,
padding: mobile ? '10px 12px' : '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderBottom: '1px solid var(--color-border)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: isDragging ? 'rgba(59,130,246,0.08)' : 'var(--color-bg-card, rgba(79,70,229,0.02))',
boxShadow: isDragging ? 'var(--shadow-sm)' : 'none',
gap: mobile ? 8 : 6,
padding: mobile ? '8px 9px' : '5px 8px',
border: `1px solid ${dragging ? 'color-mix(in srgb, var(--color-info) 38%, var(--color-border-light))' : 'color-mix(in srgb, var(--color-border-light) 92%, transparent)'}`,
borderRadius: 14,
backgroundColor: dragging
? 'color-mix(in srgb, var(--color-bg-card) 82%, var(--color-info) 18%)'
: 'color-mix(in srgb, var(--color-bg-card) 96%, white 4%)',
boxShadow: dragging
? '0 18px 34px rgba(15, 23, 42, 0.12)'
: '0 10px 22px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.7)',
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
@@ -79,27 +92,19 @@ export function SortableChannelRow({
if (mobile) {
return (
<div ref={setNodeRef} style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div data-layer-root style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority || readOnly}
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
marginTop: 2,
...dragHandleStyle,
}}
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -112,8 +117,8 @@ export function SortableChannelRow({
</svg>
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
{showPriorityBadge ? (
<span
className="badge"
@@ -143,7 +148,7 @@ export function SortableChannelRow({
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
@@ -161,14 +166,14 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
@@ -183,22 +188,22 @@ export function SortableChannelRow({
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, 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 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
@@ -209,12 +214,12 @@ export function SortableChannelRow({
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -239,7 +244,7 @@ export function SortableChannelRow({
</div>
{!managementLocked && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 6, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
@@ -260,12 +265,12 @@ export function SortableChannelRow({
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
@@ -306,26 +311,16 @@ export function SortableChannelRow({
}
return (
<div ref={setNodeRef} style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
<div data-layer-root style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 6, fontSize: 12, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority || readOnly}
ref={dragHandleRef}
{...dragHandleProps}
disabled={isSavingPriority || managementLocked}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
}}
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
style={dragHandleStyle}
data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -377,14 +372,14 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
background: 'var(--color-info-soft)',
color: 'var(--color-info)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
@@ -399,7 +394,7 @@ export function SortableChannelRow({
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
>
手动配置
</span>
@@ -409,15 +404,15 @@ export function SortableChannelRow({
<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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, 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 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
width: 60,
height: 4,
background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999,
overflow: 'hidden',
}}
@@ -428,12 +423,12 @@ export function SortableChannelRow({
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
transition: 'width 0.24s ease, background-color 0.18s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -455,66 +450,6 @@ export function SortableChannelRow({
</div>
{!managementLocked ? (
mobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
>
站点屏蔽
</button>
) : null}
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
移除
</button>
</div>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
@@ -537,7 +472,7 @@ export function SortableChannelRow({
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
<div style={{ marginTop: 3, fontSize: 10.5, color: 'var(--color-text-muted)', lineHeight: 1.35 }}>
{tokenBinding.helperText}
</div>
</div>
@@ -553,17 +488,17 @@ export function SortableChannelRow({
<button
onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`}
data-tooltip={channel.enabled === false ? '启用此通道' : '禁用此通道'}
data-tooltip={suppressTooltips ? undefined : (channel.enabled === false ? '启用此通道' : '禁用此通道')}
>
{channel.enabled === false ? '启用' : '禁用'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{onSiteBlockModel && channel.site?.id ? (
<button
onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning"
data-tooltip={`将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
data-tooltip={suppressTooltips ? undefined : `将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
>
站点屏蔽
</button>
@@ -577,7 +512,6 @@ export function SortableChannelRow({
</button>
</div>
</>
)
) : null}
</div>
);
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
describe('translateOnlyRectSortingStrategy', () => {
it('uses rect deltas without introducing scale for variable-height rows', () => {
const rects = [
{ top: 0, left: 0, width: 200, height: 80, right: 200, bottom: 80 },
{ top: 92, left: 0, width: 200, height: 56, right: 200, bottom: 148 },
{ top: 160, left: 0, width: 200, height: 120, right: 200, bottom: 280 },
];
const siblingTransform = translateOnlyRectSortingStrategy({
rects,
activeIndex: 0,
overIndex: 2,
index: 1,
activeNodeRect: rects[0],
});
expect(siblingTransform).toEqual({
x: 0,
y: -92,
scaleX: 1,
scaleY: 1,
});
});
});
@@ -0,0 +1,23 @@
import { arrayMove, type SortingStrategy } from '@dnd-kit/sortable';
export const translateOnlyRectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
const newRects = arrayMove(rects, overIndex, activeIndex);
const oldRect = rects[index];
const newRect = newRects[index];
if (!newRect || !oldRect) {
return null;
}
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: 1,
scaleY: 1,
};
};
+5 -1
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import type { ButtonHTMLAttributes, ReactNode, RefCallback } from 'react';
import type { BrandInfo } from '../../components/BrandIcon.js';
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
@@ -127,12 +127,16 @@ export type SortableChannelRowProps = {
channel: RouteChannel;
displayPriority?: number;
showPriorityBadge?: boolean;
dragging?: boolean;
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>;
dragHandleRef?: RefCallback<HTMLButtonElement>;
decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean;
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
channelManagementDisabled?: boolean;
dragInProgress?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;
@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes, { DesktopDetailPanelPresence } from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecision: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
rebuildRoutes: vi.fn(),
deleteRoute: vi.fn(),
deleteChannel: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => false,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes desktop detail panel', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
beforeEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = class {
observe() {}
disconnect() {}
unobserve() {}
takeRecords() { return []; }
readonly root = null;
readonly rootMargin = '0px';
readonly thresholds = [];
} as unknown as typeof IntersectionObserver;
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
{
id: 2,
modelPattern: 'claude-3.7-sonnet',
displayName: 'claude-3.7-sonnet',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-b'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-4o-mini',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecision.mockResolvedValue({ decision: null });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.rebuildRoutes.mockResolvedValue({ rebuild: { createdRoutes: 0, createdChannels: 0 } });
apiMock.deleteRoute.mockResolvedValue({});
apiMock.deleteChannel.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
});
it('keeps summary cards stable and opens a separate desktop detail panel', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
))).toHaveLength(2);
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const collapsedCardsAfterExpand = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
expect(collapsedCardsAfterExpand).toHaveLength(2);
const detailPanels = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-detail-panel')
));
expect(detailPanels).toHaveLength(1);
const detailPanelText = collectText(detailPanels[0]!);
expect(detailPanelText).toContain('gpt-4o-mini');
expect(detailPanelText).toContain('路由策略');
} finally {
root?.unmount();
}
});
it('animates the desktop detail panel closed before unmounting it', async () => {
vi.useFakeTimers();
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const closeButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('收起详情')
));
await act(async () => {
await closeButton.props.onClick();
});
await flushMicrotasks();
let detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(1);
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('anim-collapse');
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('is-open');
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
const detailPanelsWhileClosing = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-detail-panel')
));
expect(detailPanelsWhileClosing).toHaveLength(1);
const summaryCardWhileClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardWhileClosing.props.className || '')).toContain('is-active');
await act(async () => {
vi.advanceTimersByTime(260);
});
detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(0);
const summaryCardAfterClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardAfterClosing.props.className || '')).not.toContain('is-active');
} finally {
root?.unmount();
vi.useRealTimers();
}
});
it('does not schedule a close timer before the desktop detail panel has ever opened', async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<DesktopDetailPanelPresence open={false}>
{() => <div>detail</div>}
</DesktopDetailPanelPresence>,
);
});
await flushMicrotasks();
expect(root.toJSON()).toBeNull();
expect(setTimeoutSpy).not.toHaveBeenCalled();
} finally {
root?.unmount();
vi.useRealTimers();
}
});
});
@@ -1569,7 +1569,7 @@ describe('TokenRoutes grouped source models', () => {
expect(expandedText).toContain('P0');
expect(expandedText).toContain('当前生效:token-a');
expect(expandedText).toContain('选中概率');
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级桶').props.disabled).toBe(false);
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级桶').props.disabled).toBe(true);
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '保存')).toHaveLength(0);
expect(root.root.findAll((node) => node.type === 'button' && collectText(node).trim() === '移除')).toHaveLength(0);
} finally {