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