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); 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 { .proxy-logs-filter-select {
min-width: 180px; min-width: 180px;
} }
@@ -5625,91 +5643,62 @@ textarea {
/* ===== Route Filter Bar (Collapsible, horizontal chips) ===== */ /* ===== Route Filter Bar (Collapsible, horizontal chips) ===== */
.route-filter-bar { .route-filter-bar {
margin-bottom: 12px; margin-bottom: 8px;
border: 1px solid var(--color-border); border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent);
border-radius: var(--radius-sm); border-radius: 14px;
background: var(--color-bg-card); 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 { .route-filter-bar-summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 7px;
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 10px;
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 12.5px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: left; text-align: left;
border-radius: var(--radius-sm); transition: background-color 0.15s ease, color 0.15s ease, transform 0.18s ease;
transition: background 0.15s;
} }
.route-filter-bar-summary:hover { .route-filter-bar-summary:hover {
background: var(--color-bg); background: color-mix(in srgb, var(--color-bg) 74%, white 26%);
} }
.route-filter-bar-expanded { .route-filter-bar-expanded {
padding: 8px 12px 12px; padding: 8px 10px 10px;
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; 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 { .route-filter-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 7px;
} }
.route-filter-row-label { .route-filter-row-label {
min-width: 40px; min-width: 46px;
font-size: 12px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--color-text-secondary); color: var(--color-text-secondary);
padding-top: 5px; padding-top: 6px;
flex-shrink: 0; flex-shrink: 0;
letter-spacing: 0.02em;
} }
.route-filter-row-chips { .route-filter-row-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 4px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -5718,30 +5707,66 @@ textarea {
.filter-chip { .filter-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
padding: 4px 10px; padding: 3px 9px;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--color-border); border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: transparent; background: color-mix(in srgb, var(--color-bg-card) 90%, white 10%);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 11px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: nowrap; 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; line-height: 1.3;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
} }
.filter-chip:hover { .filter-chip:hover {
background: var(--color-bg); background: color-mix(in srgb, var(--color-bg) 78%, white 22%);
border-color: var(--color-primary); border-color: color-mix(in srgb, var(--color-text-muted) 22%, var(--color-border));
color: var(--color-primary); color: var(--color-text-primary);
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.05);
transform: translateY(-1px);
} }
.filter-chip.active { .filter-chip.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent); background: color-mix(in srgb, var(--color-info) 10%, var(--color-bg-card));
border-color: var(--color-primary); border-color: color-mix(in srgb, var(--color-info) 36%, var(--color-border));
color: var(--color-primary); color: var(--color-info);
font-weight: 500; 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 { .filter-chip-icon {
@@ -5766,41 +5791,137 @@ textarea {
} }
.filter-chip.active .filter-chip-count { .filter-chip.active .filter-chip-count {
color: var(--color-primary); color: var(--color-info);
} }
/* ===== Route Card Grid (multi-column) ===== */ /* ===== Route Card Grid (multi-column) ===== */
.route-card-grid { .route-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 12px; gap: 10px;
align-items: start; align-items: start;
} }
/* Collapsed route card */ /* Collapsed route card */
.route-card-collapsed { .route-card-collapsed {
padding: 10px 14px; padding: 8px 10px;
transition: box-shadow 0.15s ease, border-color 0.15s ease; 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 { .route-card-collapsed:hover {
border-color: var(--color-primary); border-color: color-mix(in srgb, var(--color-info) 28%, var(--color-border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent); 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 */ /* Expanded route card spans full width */
.route-card-expanded { .route-card-expanded {
grid-column: 1 / -1; 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 { .route-card-expanded-compact {
margin-top: -2px; background: color-mix(in srgb, var(--color-bg-card) 96%, white 4%);
border-top-left-radius: 18px; }
border-top-right-radius: 18px;
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary) 8%); .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) { @media (max-width: 768px) {
.route-batch-bar {
border-radius: 16px;
padding: 9px 10px;
}
.route-card-grid { .route-card-grid {
grid-template-columns: 1fr; 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 { useNavigate } from 'react-router-dom';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import { api } from '../api.js'; import { api } from '../api.js';
@@ -93,6 +93,7 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
sourceRouteIds: [], sourceRouteIds: [],
advancedOpen: false, advancedOpen: false,
}; };
const DESKTOP_DETAIL_COLLAPSE_MS = 180;
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string { function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
if (value === 'round_robin') return '已切换为轮询策略'; 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;'); 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() { export default function TokenRoutes() {
const navigate = useNavigate(); const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]); const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -145,8 +201,10 @@ export default function TokenRoutes() {
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK); const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({}); const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]); const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
const [closingDesktopDetailRouteIds, setClosingDesktopDetailRouteIds] = useState<number[]>([]);
const [addChannelModalRouteId, setAddChannelModalRouteId] = useState<number | null>(null); const [addChannelModalRouteId, setAddChannelModalRouteId] = useState<number | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const desktopDetailCloseTimersRef = useRef<Record<number, ReturnType<typeof globalThis.setTimeout>>>({});
const { const {
channelsByRouteId, channelsByRouteId,
@@ -914,6 +972,14 @@ export default function TokenRoutes() {
return hints; 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 // Lazy per-route missing token group index
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() }); const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}|${candidatesVersionRef.current}`; const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}|${candidatesVersionRef.current}`;
@@ -933,6 +999,14 @@ export default function TokenRoutes() {
return hints; 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( const routeById = useMemo(
() => new Map(visibleRouteRows.map((route) => [route.id, route])), () => new Map(visibleRouteRows.map((route) => [route.id, route])),
[visibleRouteRows], [visibleRouteRows],
@@ -1207,8 +1281,25 @@ export default function TokenRoutes() {
const toggleExpand = async (routeId: number) => { const toggleExpand = async (routeId: number) => {
const isCurrentlyExpanded = expandedRouteIds.includes(routeId); const isCurrentlyExpanded = expandedRouteIds.includes(routeId);
if (isCurrentlyExpanded) { 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)); setExpandedRouteIds((prev) => prev.filter((id) => id !== routeId));
} else { } else {
const existingTimer = desktopDetailCloseTimersRef.current[routeId];
if (existingTimer) {
globalThis.clearTimeout(existingTimer);
delete desktopDetailCloseTimersRef.current[routeId];
}
setClosingDesktopDetailRouteIds((prev) => prev.filter((id) => id !== routeId));
loadCandidates(); loadCandidates();
setExpandedRouteIds((prev) => [...prev, routeId]); setExpandedRouteIds((prev) => [...prev, routeId]);
// Load channels on demand // 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 getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
const cached = missingTokenSiteItemsCacheRef.current.cache.get(routeId);
if (cached) return cached;
const missingTokenHints = getRouteMissingTokenHints(routeId); const missingTokenHints = getRouteMissingTokenHints(routeId);
if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS; if (missingTokenHints.length === 0) return EMPTY_MISSING_ITEMS;
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>(); 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' }) a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
)); ));
missingTokenSiteItemsCacheRef.current.cache.set(routeId, items);
return items;
}; };
const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => { const getMissingTokenGroupItems = (routeId: number): MissingTokenGroupRouteSiteActionItem[] => {
const cached = missingTokenGroupItemsCacheRef.current.cache.get(routeId);
if (cached) return cached;
const missingGroupHints = getRouteMissingTokenGroupHints(routeId); const missingGroupHints = getRouteMissingTokenGroupHints(routeId);
if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS; if (missingGroupHints.length === 0) return EMPTY_MISSING_GROUP_ITEMS;
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>(); 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' }) 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) // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
@@ -1386,7 +1492,7 @@ export default function TokenRoutes() {
return ( return (
<div className="animate-fade-in" style={{ minHeight: 400 }}> <div className="animate-fade-in" style={{ minHeight: 400 }}>
{/* Toolbar: search + sort + actions */} {/* 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 }}> <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"> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -1422,7 +1528,7 @@ export default function TokenRoutes() {
</div> </div>
<button <button
className="btn btn-ghost" 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'))} onClick={() => setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
data-tooltip={tr('切换排序方向')} data-tooltip={tr('切换排序方向')}
aria-label={tr('切换排序方向')} aria-label={tr('切换排序方向')}
@@ -1436,7 +1542,7 @@ export default function TokenRoutes() {
onClick={handleRefreshRouteDecisions} onClick={handleRefreshRouteDecisions}
disabled={loadingDecision} disabled={loadingDecision}
className="btn btn-ghost" 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 ? ( {loadingDecision ? (
<><span className="spinner spinner-sm" /> {tr('刷新中...')}</> <><span className="spinner spinner-sm" /> {tr('刷新中...')}</>
@@ -1449,7 +1555,7 @@ export default function TokenRoutes() {
onClick={handleRebuild} onClick={handleRebuild}
disabled={rebuilding} disabled={rebuilding}
className="btn btn-ghost" 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 ? ( {rebuilding ? (
<><span className="spinner spinner-sm" /> {tr('重建中...')}</> <><span className="spinner spinner-sm" /> {tr('重建中...')}</>
@@ -1465,7 +1571,7 @@ export default function TokenRoutes() {
setShowManual(true); setShowManual(true);
}} }}
className="btn btn-ghost" 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('新建群组')} {tr('新建群组')}
</button> </button>
@@ -1473,7 +1579,7 @@ export default function TokenRoutes() {
<button <button
onClick={toggleBatchSelectMode} onClick={toggleBatchSelectMode}
className={`btn ${batchSelectMode ? 'btn-primary' : 'btn-ghost'}`} 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('批量操作')} {batchSelectMode ? tr('退出批量') : tr('批量操作')}
</button> </button>
@@ -1486,7 +1592,7 @@ export default function TokenRoutes() {
setShowZeroChannelRoutes((prev) => !prev); setShowZeroChannelRoutes((prev) => !prev);
}} }}
className="btn btn-ghost" 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 通道路由')} {showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')}
</button> </button>
@@ -1507,7 +1613,7 @@ export default function TokenRoutes() {
mobileTrigger={( mobileTrigger={(
<button <button
className="btn btn-ghost" 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={() => { onClick={() => {
loadCandidates(); loadCandidates();
setShowFilters(true); 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 */} {/* Manual route panel */}
<ManualRoutePanel <ManualRoutePanel
show={showManual} show={showManual}
@@ -1594,15 +1690,7 @@ export default function TokenRoutes() {
{/* Route card grid */} {/* Route card grid */}
{/* Batch selection floating bar */} {/* Batch selection floating bar */}
{batchSelectMode && ( {batchSelectMode && (
<div style={{ <div className="route-batch-bar">
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)',
}}>
<span style={{ fontSize: 13, fontWeight: 500 }}> <span style={{ fontSize: 13, fontWeight: 500 }}>
{tr('已选择')} <b>{selectedRouteIds.size}</b> / {selectableRouteIds.size} {tr('条路由')} {tr('已选择')} <b>{selectedRouteIds.size}</b> / {selectableRouteIds.size} {tr('条路由')}
</span> </span>
@@ -1632,6 +1720,7 @@ export default function TokenRoutes() {
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}> <div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => { {visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id); 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 isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const exactRoute = isRouteExactModel(route); const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route); const explicitGroupRoute = isExplicitGroupRoute(route);
@@ -1758,12 +1847,51 @@ export default function TokenRoutes() {
); );
} }
const routeCard = ( const summaryCard = (
<RouteCard <RouteCard
key={route.id}
route={route} route={route}
brand={routeBrandById.get(route.id) || null} 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} onToggleExpand={stableToggleExpand}
onEdit={stableEditRoute} onEdit={stableEditRoute}
onDelete={stableDeleteRoute} onDelete={stableDeleteRoute}
@@ -1793,11 +1921,14 @@ export default function TokenRoutes() {
expandedSourceGroupMap={expandedSourceGroupMap} expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup} onToggleSourceGroup={stableToggleSourceGroup}
/> />
)}
</DesktopDetailPanelPresence>
); );
if (batchSelectMode && isSelectable) { if (batchSelectMode && isSelectable) {
return ( 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 <div
onClick={() => toggleRouteSelection(route.id)} onClick={() => toggleRouteSelection(route.id)}
style={{ style={{
@@ -1821,13 +1952,20 @@ export default function TokenRoutes() {
/> />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{routeCard} {summaryCard}
</div> </div>
</div> </div>
{detailPanel}
</Fragment>
); );
} }
return routeCard; return (
<Fragment key={route.id}>
{summaryCard}
{detailPanel}
</Fragment>
);
})} })}
</div> </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 { 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 RouteCard from './RouteCard.js';
import type { RouteChannel, RouteSummaryRow } from './types.js'; import type { RouteChannel, RouteSummaryRow } from './types.js';
import { getRouteRoutingStrategyDescription } from './routingStrategy.js';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
@@ -97,15 +100,74 @@ describe('RouteCard', () => {
)); ));
expect(regexBadge.props.style).toMatchObject({ expect(regexBadge.props.style).toMatchObject({
maxWidth: 180, flex: '0 1 116px',
maxWidth: 116,
minWidth: 0, minWidth: 0,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', 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', () => { it('renders a clear cooldown action on expanded cards', () => {
const onClearCooldown = vi.fn(); const onClearCooldown = vi.fn();
const root = create( const root = create(
@@ -154,6 +216,60 @@ describe('RouteCard', () => {
expect(onClearCooldown).toHaveBeenCalledTimes(1); 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', () => { it('renders desktop priority rail summaries for multiple channel layers', () => {
const root = create( const root = create(
<RouteCard <RouteCard
@@ -229,4 +345,433 @@ describe('RouteCard', () => {
expect(p1RailNode.props.style.background).not.toBe('var(--color-bg)'); expect(p1RailNode.props.style.background).not.toBe('var(--color-bg)');
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color); 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 { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
closestCenter, closestCenter,
type DragEndEvent, type DragEndEvent,
type DragOverEvent,
type DragStartEvent, type DragStartEvent,
useDroppable, useDroppable,
useSensor, useSensor,
@@ -13,12 +12,12 @@ import {
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
SortableContext, SortableContext,
verticalListSortingStrategy,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js'; import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
import ModernSelect from '../../components/ModernSelect.js'; import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js'; import { tr } from '../../i18n.js';
import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js'; import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
import type { import type {
@@ -53,12 +52,15 @@ import {
createPriorityRailNewLayerId, createPriorityRailNewLayerId,
isPriorityRailNewLayerId, isPriorityRailNewLayerId,
} from './priorityRail.js'; } from './priorityRail.js';
import { translateOnlyRectSortingStrategy } from './sortingStrategies.js';
type RouteCardProps = { type RouteCardProps = {
route: RouteSummaryRow; route: RouteSummaryRow;
brand: BrandInfo | null; brand: BrandInfo | null;
expanded: boolean; expanded: boolean;
compact?: boolean; compact?: boolean;
summaryExpanded?: boolean;
detailPanel?: boolean;
onToggleExpand: (routeId: number) => void; onToggleExpand: (routeId: number) => void;
onEdit: (route: RouteSummaryRow) => void; onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void; onDelete: (routeId: number) => void;
@@ -96,18 +98,6 @@ type RouteCardProps = {
onToggleSourceGroup: (groupKey: string) => void; 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({ function PriorityRailNewLayerRow({
id, id,
highlighted, highlighted,
@@ -142,7 +132,7 @@ function PriorityRailNewLayerRow({
fontWeight: 600, fontWeight: 600,
textAlign: 'center', textAlign: 'center',
lineHeight: 1.2, lineHeight: 1.2,
transition: 'all 0.16s ease', transition: 'border-color 0.16s ease, background 0.16s ease, color 0.16s ease',
}} }}
> >
{tr('放到新档位')} {tr('放到新档位')}
@@ -152,18 +142,218 @@ function PriorityRailNewLayerRow({
height: 0, height: 0,
borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`, borderTop: `1px dashed ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
opacity: active ? 1 : 0.75, opacity: active ? 1 : 0.75,
transition: 'all 0.16s ease', transition: 'border-color 0.16s ease, opacity 0.16s ease',
}} }}
/> />
</div> </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({ function RouteCardInner({
route, route,
brand, brand,
expanded, expanded,
compact = false, compact = false,
summaryExpanded = false,
detailPanel = false,
onToggleExpand, onToggleExpand,
onEdit, onEdit,
onDelete, onDelete,
@@ -207,6 +397,8 @@ function RouteCardInner({
const cachedDecisionTooltip = route.decisionRefreshedAt const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}` ? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
: undefined; : undefined;
const showAddChannelButton = !readOnlyRoute && !channelManagementDisabled;
const showMissingTokenHints = !channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0);
const routingStrategyOptions = [ const routingStrategyOptions = [
{ {
value: 'weighted', value: 'weighted',
@@ -237,30 +429,14 @@ function RouteCardInner({
const priorityBuckets = buildPriorityBuckets(channels || []); const priorityBuckets = buildPriorityBuckets(channels || []);
const priorityRailSections = buildPriorityRailSections(channels || []); const priorityRailSections = buildPriorityRailSections(channels || []);
const [activeDragChannelId, setActiveDragChannelId] = useState<number | null>(null); 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 = () => { const clearDragState = () => {
setActiveDragChannelId(null); setActiveDragChannelId(null);
setDragOverId(null);
}; };
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
const nextId = Number(event.active.id); const nextId = Number(event.active.id);
setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null); setActiveDragChannelId(Number.isFinite(nextId) ? nextId : null);
setDragOverId(event.active.id);
};
const handleDragOver = (event: DragOverEvent) => {
setDragOverId(event.over?.id ?? null);
}; };
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
@@ -275,13 +451,47 @@ function RouteCardInner({
</button> </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 // Collapsed card
if (!expanded) { if (!expanded) {
return ( return (
<div <div
className="card route-card-collapsed" className={`card route-card-collapsed ${summaryExpanded ? 'is-active' : ''}`.trim()}
onClick={() => onToggleExpand(route.id)} 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' }} style={{ cursor: 'pointer' }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
@@ -297,7 +507,22 @@ function RouteCardInner({
) : null} ) : null}
</span> </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} {title}
</code> </code>
@@ -307,9 +532,9 @@ function RouteCardInner({
title={route.modelPattern} title={route.modelPattern}
style={{ style={{
fontSize: 10, fontSize: 10,
flexShrink: 1, flex: '0 1 116px',
minWidth: 0, minWidth: 0,
maxWidth: 180, maxWidth: 116,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -318,6 +543,7 @@ function RouteCardInner({
{route.modelPattern} {route.modelPattern}
</span> </span>
) : null} ) : null}
</div>
{readOnlyRoute ? ( {readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}> <span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
@@ -374,7 +600,12 @@ function RouteCardInner({
<svg <svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ 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 aria-hidden
> >
<path d="m5 7 5 6 5-6" /> <path d="m5 7 5 6 5-6" />
@@ -386,7 +617,10 @@ function RouteCardInner({
// Expanded card // Expanded card
return ( 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 ? ( {!compact ? (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}> <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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -477,28 +711,35 @@ function RouteCardInner({
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}> data-testid="compact-route-header-main"
{tr('路由详情与通道管理')} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 5, flexWrap: 'wrap', minWidth: 0, flex: 1 }}
</div> >
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}> <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} {title}
</div> </code>
</div> {route.displayName && route.displayName.trim() !== route.modelPattern ? (
{!readOnlyRoute && ( <span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}> ) : null}
{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' }}>
{readOnlyRoute ? ( {readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('未生成')}</span> <span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('未生成')}</span>
) : ( ) : (
@@ -525,25 +766,98 @@ function RouteCardInner({
) : null} ) : null}
{savingPriority ? <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> : null} {savingPriority ? <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> : null}
</div> </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> </div>
)} )}
{explicitGroupRoute ? ( {!compact && explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}> <div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')} {tr('该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。')}
</div> </div>
) : !exactRoute ? ( ) : !compact && !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}> <div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')} {tr('通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。')}
</div> </div>
) : null} ) : null}
{!readOnlyRoute && ( {!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'center', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}> <div
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', minWidth: compact ? '100%' : undefined }}> 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('路由策略')} {tr('路由策略')}
</div> </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 <ModernSelect
size="sm" size="sm"
value={routingStrategy} value={routingStrategy}
@@ -553,25 +867,27 @@ function RouteCardInner({
placeholder={tr('选择路由策略')} placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')} emptyLabel={tr('暂无可选策略')}
/> />
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ fontSize: 12, lineHeight: 1.55, color: 'var(--color-text-secondary)' }}> <div style={{ fontSize: 11.5, lineHeight: 1.45, color: 'var(--color-text-secondary)' }}>
{routingStrategyDescription} {routingStrategyDescription}
</div> </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} {routingStrategyHint}
</div> </div>
</div> </div>
</div> </div>
</>
)}
</div> </div>
)} )}
{/* Missing token hints + Add channel button */} {/* 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' }}> <div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'flex-start', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? ( {showMissingTokenHints ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1 }}>
{missingTokenSiteItems.length > 0 && ( {missingTokenSiteItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span> <span style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
{missingTokenSiteItems.map((item) => ( {missingTokenSiteItems.map((item) => (
<button <button
key={`missing-${route.id}-${item.key}`} key={`missing-${route.id}-${item.key}`}
@@ -579,7 +895,7 @@ function RouteCardInner({
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)} onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag" className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`} data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{ fontSize: 11, cursor: 'pointer' }} style={{ fontSize: 10.5, cursor: 'pointer' }}
> >
{item.siteName} {item.siteName}
</button> </button>
@@ -587,8 +903,8 @@ function RouteCardInner({
</div> </div>
)} )}
{missingTokenGroupItems.length > 0 && ( {missingTokenGroupItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span> <span style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
{missingTokenGroupItems.map((item) => ( {missingTokenGroupItems.map((item) => (
<button <button
key={`missing-group-${route.id}-${item.key}`} key={`missing-group-${route.id}-${item.key}`}
@@ -596,7 +912,7 @@ function RouteCardInner({
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)} onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-warning missing-token-group-tag" className="badge badge-warning missing-token-group-tag"
data-tooltip={`缺少分组:${item.missingGroups.join('、') || '未知'}${item.availableGroups.length > 0 ? `;已覆盖:${item.availableGroups.join('、')}` : ''}${item.groupCoverageUncertain ? ';当前分组覆盖存在不确定性' : ''}`} 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} {item.siteName}
</button> </button>
@@ -604,162 +920,79 @@ function RouteCardInner({
</div> </div>
)} )}
</div> </div>
) : <div />} ) : (!compact && showAddChannelButton ? <div /> : null)}
{!readOnlyRoute && !channelManagementDisabled && ( {!compact && showAddChannelButton ? renderAddChannelButton() : null}
<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>
)}
</div> </div>
{/* Channel list */} {/* Channel list */}
{loadingChannels ? ( {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 className="spinner spinner-sm" />
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{tr('加载通道中...')}</span> <span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{tr('加载通道中...')}</span>
</div> </div>
) : channels && channels.length > 0 ? ( ) : channels && channels.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragCancel={clearDragState} onDragCancel={clearDragState}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext items={(channels || []).map((channel) => channel.id)} strategy={verticalListSortingStrategy}> <SortableContext items={(channels || []).map((channel) => channel.id)} strategy={translateOnlyRectSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 10 : 12 }}> <div
data-testid="route-channel-sortable-list"
style={{ display: 'flex', flexDirection: 'column', gap: compact ? 8 : 4 }}
>
{priorityBuckets.map((bucket, bucketIndex) => { {priorityBuckets.map((bucket, bucketIndex) => {
const railSection = priorityRailSections[bucketIndex]; const railSection = priorityRailSections[bucketIndex];
const railLabel = `P${bucketIndex} · ${bucket.channels.length}`; const railLabel = `P${bucketIndex} · ${bucket.channels.length}`;
const mobileRailLabel = `${railLabel} ${tr('通道')}`; const mobileRailLabel = `${railLabel} ${tr('通道')}`;
const hoveredExistingLayer = activeDragChannelId != null && hoveredBucketIndex === bucketIndex; const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, false);
const hoveredCrossLayer = hoveredExistingLayer && activeDragBucketIndex !== bucketIndex; const showNewLayerTarget = !compact && activeDragChannelId != null && !readOnlyRoute;
const hoveredNewLayer = activeDragChannelId != null && hoveredNewLayerBucketIndex === bucketIndex;
const railNodeStyle = buildPriorityRailNodeStyle(bucketIndex, hoveredExistingLayer);
return ( return (
<div <Fragment key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`}>
key={`${route.id}-priority-bucket-${bucket.priority}-${bucketIndex}`} {bucket.channels.map((channel, channelIndex) => {
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;
return ( return (
<SortableChannelRow <SortableChannelShell
key={channel.id} key={channel.id}
channel={channel} channel={channel}
displayPriority={bucketIndex} bucketIndex={bucketIndex}
showPriorityBadge={compact} channelIndex={channelIndex}
decisionCandidate={decisionMap.get(channel.id)} bucketChannelCount={bucket.channels.length}
isExactRoute={exactRoute} totalBucketCount={priorityBuckets.length}
compact={compact}
readOnlyRoute={readOnlyRoute}
savingPriority={savingPriority}
candidateView={candidateView}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
activeDragChannelId={activeDragChannelId}
decisionMap={decisionMap}
exactRoute={exactRoute}
loadingDecision={loadingDecision} loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
readOnly={readOnlyRoute}
channelManagementDisabled={channelManagementDisabled} channelManagementDisabled={channelManagementDisabled}
mobile={compact} routeId={route.id}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange} onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)} onSaveToken={onSaveToken}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} onDeleteChannel={onDeleteChannel}
onToggleEnabled={(enabled) => onToggleChannelEnabled(channel.id, route.id, enabled)} onToggleChannelEnabled={onToggleChannelEnabled}
onSiteBlockModel={channelManagementDisabled ? undefined : () => onSiteBlockModel(channel.id, route.id)} onSiteBlockModel={onSiteBlockModel}
railLabel={railSection ? `P${bucketIndex} · ${railSection.channelCount}` : railLabel}
mobileRailLabel={mobileRailLabel}
railNodeStyle={railNodeStyle}
/> />
); );
})} })}
</div>
</div>
{!compact && activeDragChannelId != null && !readOnlyRoute ? ( {showNewLayerTarget ? (
<PriorityRailNewLayerRow <PriorityRailNewLayerRow
id={createPriorityRailNewLayerId(bucket.priority)} id={createPriorityRailNewLayerId(bucket.priority)}
highlighted={hoveredNewLayer} highlighted={false}
/> />
) : null} ) : null}
</div> </Fragment>
); );
})} })}
</div> </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; 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 { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js'; import { tr } from '../../i18n.js';
import type { GroupFilter, GroupRouteItem } from './types.js'; import type { GroupFilter, GroupRouteItem } from './types.js';
import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
export type EnabledFilter = 'all' | 'enabled' | 'disabled'; export type EnabledFilter = 'all' | 'enabled' | 'disabled';
const FILTER_EXPANDED_CONTENT_UNMOUNT_MS = 180;
type RouteFilterBarProps = { type RouteFilterBarProps = {
totalRouteCount: number; totalRouteCount: number;
activeBrand: string | null; activeBrand: string | null;
@@ -104,8 +105,31 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
collapsed, collapsed,
onToggle, onToggle,
} = props; } = 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 ( return (
<div className="route-filter-bar"> <div className="route-filter-bar">
@@ -133,8 +157,10 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
</button> </button>
{/* Expanded panel */} {/* Expanded panel */}
{expandPresence.shouldRender && ( <div className={`anim-collapse route-filter-bar-presence ${presenceOpen ? 'is-open' : ''}`.trim()}>
<div className={`route-filter-bar-expanded ${expandPresence.isVisible ? '' : 'is-closing'}`.trim()}> <div className="anim-collapse-inner">
{renderExpandedContent && (
<div className="route-filter-bar-expanded">
{/* Status row */} {/* Status row */}
<FilterRow label={tr('状态')}> <FilterRow label={tr('状态')}>
<FilterChip <FilterChip
@@ -296,5 +322,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
</div> </div>
)} )}
</div> </div>
</div>
</div>
); );
} }
@@ -68,10 +68,85 @@ describe('SortableChannelRow layering', () => {
const row = root.root.find((node) => ( const row = root.root.find((node) => (
node.type === 'div' node.type === 'div'
&& node.props.style && node.props['data-layer-root']
&& node.props.style.borderLeft === '2px solid var(--color-primary)'
)); ));
expect(row.props.style.zIndex).toBeUndefined(); 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 { useState, type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js'; import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js'; import type { SortableChannelRowProps } from './types.js';
import { import {
@@ -15,12 +13,16 @@ export function SortableChannelRow({
channel, channel,
displayPriority, displayPriority,
showPriorityBadge = true, showPriorityBadge = true,
dragging = false,
dragHandleProps,
dragHandleRef,
decisionCandidate, decisionCandidate,
isExactRoute, isExactRoute,
loadingDecision, loadingDecision,
isSavingPriority, isSavingPriority,
readOnly = false, readOnly = false,
channelManagementDisabled = false, channelManagementDisabled = false,
dragInProgress = false,
mobile = false, mobile = false,
tokenOptions, tokenOptions,
activeTokenId, activeTokenId,
@@ -31,37 +33,48 @@ export function SortableChannelRow({
onToggleEnabled, onToggleEnabled,
onSiteBlockModel, onSiteBlockModel,
}: SortableChannelRowProps) { }: SortableChannelRowProps) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: channel.id,
disabled: isSavingPriority || readOnly,
});
const resolvedPriority = displayPriority ?? channel.priority ?? 0; const resolvedPriority = displayPriority ?? channel.priority ?? 0;
const managementLocked = readOnly || channelManagementDisabled; 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 = { const rowStyle: CSSProperties = {
transform: CSS.Transform.toString(transform), transition: rowTransition || undefined,
transition, opacity: dragging ? 0.92 : channel.enabled === false ? 0.56 : 1,
opacity: isDragging ? 0.72 : channel.enabled === false ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
display: 'grid', display: 'grid',
gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto', gridTemplateColumns: managementLocked || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto auto',
alignItems: mobile ? 'stretch' : 'center', alignItems: mobile ? 'stretch' : 'center',
gap: 8, gap: mobile ? 8 : 6,
padding: mobile ? '10px 12px' : '8px 12px', padding: mobile ? '8px 9px' : '5px 8px',
borderLeft: '2px solid var(--color-primary)', 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)'}`,
borderBottom: '1px solid var(--color-border)', borderRadius: 14,
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0', backgroundColor: dragging
background: isDragging ? 'rgba(59,130,246,0.08)' : 'var(--color-bg-card, rgba(79,70,229,0.02))', ? 'color-mix(in srgb, var(--color-bg-card) 82%, var(--color-info) 18%)'
boxShadow: isDragging ? 'var(--shadow-sm)' : 'none', : '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); const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
@@ -79,27 +92,19 @@ export function SortableChannelRow({
if (mobile) { if (mobile) {
return ( return (
<div ref={setNodeRef} style={{ ...rowStyle, display: 'block' }}> <div data-layer-root style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button <button
type="button" type="button"
ref={setActivatorNodeRef} ref={dragHandleRef}
{...attributes} {...dragHandleProps}
{...listeners} disabled={isSavingPriority || managementLocked}
disabled={isSavingPriority || readOnly}
className="btn btn-ghost" className="btn btn-ghost"
style={{ 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, marginTop: 2,
...dragHandleStyle,
}} }}
data-tooltip={readOnly ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'} data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
aria-label="拖拽调整优先级桶" aria-label="拖拽调整优先级桶"
> >
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden> <svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -112,8 +117,8 @@ export function SortableChannelRow({
</svg> </svg>
</button> </button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
{showPriorityBadge ? ( {showPriorityBadge ? (
<span <span
className="badge" className="badge"
@@ -143,7 +148,7 @@ export function SortableChannelRow({
</span> </span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span <span
className="badge" className="badge"
style={{ style={{
@@ -161,14 +166,14 @@ export function SortableChannelRow({
className="badge" className="badge"
style={{ style={{
fontSize: 10, fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)', background: 'var(--color-info-soft)',
color: 'var(--color-primary)', color: 'var(--color-info)',
maxWidth: 220, maxWidth: 220,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`} data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
> >
当前生效:{tokenBinding.effectiveTokenName} 当前生效:{tokenBinding.effectiveTokenName}
</span> </span>
@@ -183,22 +188,22 @@ export function SortableChannelRow({
<span <span
className="badge badge-warning" className="badge badge-warning"
style={{ fontSize: 10 }} style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成" data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
> >
手动配置 手动配置
</span> </span>
) : null} ) : null}
</div> </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> <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 <div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined} data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{ style={{
width: 80, width: 60,
height: 6, height: 4,
background: 'var(--color-border)', background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999, borderRadius: 999,
overflow: 'hidden', overflow: 'hidden',
}} }}
@@ -209,12 +214,12 @@ export function SortableChannelRow({
height: '100%', height: '100%',
background: getProbabilityColor(decisionState.probability), background: getProbabilityColor(decisionState.probability),
borderRadius: 999, borderRadius: 999,
transition: 'width 0.3s ease', transition: 'width 0.24s ease, background-color 0.18s ease',
}} }}
/> />
</div> </div>
<span <span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined} data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{ style={{
fontSize: 11, fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor, color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -239,7 +244,7 @@ export function SortableChannelRow({
</div> </div>
{!managementLocked && mobileDetailsOpen && ( {!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%' }}> <div style={{ width: '100%' }}>
<ModernSelect <ModernSelect
size="sm" size="sm"
@@ -260,12 +265,12 @@ export function SortableChannelRow({
]} ]}
placeholder="选择令牌绑定方式" 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} {tokenBinding.helperText}
</div> </div>
</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 <button
onClick={onSaveToken} onClick={onSaveToken}
disabled={isUpdatingToken} disabled={isUpdatingToken}
@@ -306,26 +311,16 @@ export function SortableChannelRow({
} }
return ( return (
<div ref={setNodeRef} style={rowStyle}> <div data-layer-root style={rowStyle}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 6, fontSize: 12, flexWrap: 'wrap', minWidth: 0 }}>
<button <button
type="button" type="button"
ref={setActivatorNodeRef} ref={dragHandleRef}
{...attributes} {...dragHandleProps}
{...listeners} disabled={isSavingPriority || managementLocked}
disabled={isSavingPriority || readOnly}
className="btn btn-ghost" className="btn btn-ghost"
style={{ style={dragHandleStyle}
width: 22, data-tooltip={suppressTooltips ? undefined : (managementLocked ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶')}
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 ? '该路由当前不可编辑优先级' : '拖拽调整优先级桶'}
aria-label="拖拽调整优先级桶" aria-label="拖拽调整优先级桶"
> >
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden> <svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
@@ -377,14 +372,14 @@ export function SortableChannelRow({
className="badge" className="badge"
style={{ style={{
fontSize: 10, fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)', background: 'var(--color-info-soft)',
color: 'var(--color-primary)', color: 'var(--color-info)',
maxWidth: 220, maxWidth: 220,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`} data-tooltip={suppressTooltips ? undefined : `当前生效:${tokenBinding.effectiveTokenName}`}
> >
当前生效:{tokenBinding.effectiveTokenName} 当前生效:{tokenBinding.effectiveTokenName}
</span> </span>
@@ -399,7 +394,7 @@ export function SortableChannelRow({
<span <span
className="badge badge-warning" className="badge badge-warning"
style={{ fontSize: 10 }} style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成" data-tooltip={suppressTooltips ? undefined : '该通道由用户手动添加,而非系统自动生成'}
> >
手动配置 手动配置
</span> </span>
@@ -409,15 +404,15 @@ export function SortableChannelRow({
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span> <span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
) : null} ) : 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> <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 <div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined} data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{ style={{
width: 80, width: 60,
height: 6, height: 4,
background: 'var(--color-border)', background: 'color-mix(in srgb, var(--color-border) 88%, white 12%)',
borderRadius: 999, borderRadius: 999,
overflow: 'hidden', overflow: 'hidden',
}} }}
@@ -428,12 +423,12 @@ export function SortableChannelRow({
height: '100%', height: '100%',
background: getProbabilityColor(decisionState.probability), background: getProbabilityColor(decisionState.probability),
borderRadius: 999, borderRadius: 999,
transition: 'width 0.3s ease', transition: 'width 0.24s ease, background-color 0.18s ease',
}} }}
/> />
</div> </div>
<span <span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined} data-tooltip={suppressTooltips ? undefined : (decisionState.probability <= 0 ? decisionState.reasonText : undefined)}
style={{ style={{
fontSize: 11, fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor, color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -455,66 +450,6 @@ export function SortableChannelRow({
</div> </div>
{!managementLocked ? ( {!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={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}> <div style={{ minWidth: 220, flex: 1 }}>
@@ -537,7 +472,7 @@ export function SortableChannelRow({
]} ]}
placeholder="选择令牌绑定方式" 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} {tokenBinding.helperText}
</div> </div>
</div> </div>
@@ -553,17 +488,17 @@ export function SortableChannelRow({
<button <button
onClick={() => onToggleEnabled(channel.enabled === false)} onClick={() => onToggleEnabled(channel.enabled === false)}
className={`btn btn-link ${channel.enabled === false ? 'btn-link-info' : 'btn-link-warning'}`} 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 ? '启用' : '禁用'} {channel.enabled === false ? '启用' : '禁用'}
</button> </button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{onSiteBlockModel && channel.site?.id ? ( {onSiteBlockModel && channel.site?.id ? (
<button <button
onClick={onSiteBlockModel} onClick={onSiteBlockModel}
className="btn btn-link btn-link-warning" className="btn btn-link btn-link-warning"
data-tooltip={`将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`} data-tooltip={suppressTooltips ? undefined : `将此模型加入站点「${channel.site?.name || '未知'}」的禁用列表,rebuild 后该站点的此模型通道将不再生成`}
> >
站点屏蔽 站点屏蔽
</button> </button>
@@ -577,7 +512,6 @@ export function SortableChannelRow({
</button> </button>
</div> </div>
</> </>
)
) : null} ) : null}
</div> </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 { BrandInfo } from '../../components/BrandIcon.js';
import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js'; import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js';
export 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; channel: RouteChannel;
displayPriority?: number; displayPriority?: number;
showPriorityBadge?: boolean; showPriorityBadge?: boolean;
dragging?: boolean;
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>;
dragHandleRef?: RefCallback<HTMLButtonElement>;
decisionCandidate?: RouteDecisionCandidate; decisionCandidate?: RouteDecisionCandidate;
isExactRoute: boolean; isExactRoute: boolean;
loadingDecision: boolean; loadingDecision: boolean;
isSavingPriority: boolean; isSavingPriority: boolean;
readOnly?: boolean; readOnly?: boolean;
channelManagementDisabled?: boolean; channelManagementDisabled?: boolean;
dragInProgress?: boolean;
mobile?: boolean; mobile?: boolean;
tokenOptions: RouteTokenOption[]; tokenOptions: RouteTokenOption[];
activeTokenId: number; 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('P0');
expect(expandedText).toContain('当前生效:token-a'); expect(expandedText).toContain('当前生效:token-a');
expect(expandedText).toContain('选中概率'); 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);
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 { } finally {