diff --git a/frontend/chimera/package.json b/frontend/chimera/package.json index 9315fd1a..658177a6 100644 --- a/frontend/chimera/package.json +++ b/frontend/chimera/package.json @@ -8,81 +8,89 @@ "build": "vite build" }, "dependencies": { - "@tauri-apps/api": "2.11.0", - "@tauri-apps/plugin-opener": "2.5.4", - "react": "19.2.7", + "@chimera/interface": "workspace:^", + "@chimera/ui": "workspace:^", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/react": "0.5.0", "@emotion/styled": "11.14.1", - "react-dom": "19.2.7", - "jotai": "2.16.1", - "@tauri-apps/plugin-updater": "2.10.1", + "@inlang/paraglide-js": "2.20.0", "@material/material-color-utilities": "0.4.0", "@mui/icons-material": "9.0.1", "@mui/lab": "9.0.0-beta.3", "@mui/material": "9.0.1", "@mui/x-date-pickers": "9.4.0", "@tailwindcss/postcss": "4.1.16", - "swr": "2.3.6", - "@chimera/ui": "workspace:^", - "@chimera/interface": "workspace:^", - "ahooks": "3.9.7", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", + "@tauri-apps/api": "2.11.0", "@tauri-apps/plugin-dialog": "2.7.1", + "@tauri-apps/plugin-fs": "2.5.1", + "@tauri-apps/plugin-notification": "2.3.3", + "@tauri-apps/plugin-opener": "2.5.4", + "@tauri-apps/plugin-os": "2.3.2", "@tauri-apps/plugin-process": "2.3.1", + "@tauri-apps/plugin-updater": "2.10.1", + "@uidotdev/usehooks": "2.4.1", + "ahooks": "3.9.7", + "change-case": "5.4.4", + "class-variance-authority": "0.7.1", + "d3": "^7.9.0", "dayjs": "1.11.21", "framer-motion": "12.40.0", - "react-fast-marquee": "1.6.5", - "react-markdown": "10.1.0", - "react-use": "17.6.1", - "virtua": "0.45.3", - "mui-color-input": "9.0.0", - "lodash-es": "4.17.22", + "jotai": "2.16.1", "json-schema": "0.4.0", - "monaco-editor": "0.55.1", - "react-hook-form-mui": "9.0.1", + "less": "4.6.4", + "lodash-es": "4.17.22", "material-react-table": "3.2.1", - "change-case": "5.4.4", - "class-variance-authority": "0.7.1", + "monaco-editor": "0.55.1", + "mui-color-input": "9.0.0", "radix-ui": "1.5.0", - "@tauri-apps/plugin-fs": "2.5.1", - "@tauri-apps/plugin-notification": "2.3.3", - "@tauri-apps/plugin-os": "2.3.2", - "@inlang/paraglide-js": "2.18.2", - "@uidotdev/usehooks": "2.4.1", - "less": "4.6.4" + "react": "19.2.7", + "react-dom": "19.2.7", + "react-fast-marquee": "1.6.5", + "react-hook-form-mui": "9.0.1", + "react-markdown": "10.1.0", + "react-use": "17.6.1", + "swr": "2.3.6", + "vaul": "1.1.2", + "virtua": "0.45.3" }, "devDependencies": { - "@tauri-apps/cli": "2.11.2", - "@tauri-apps/plugin-clipboard-manager": "2.3.2", - "@types/react": "19.2.7", - "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.2", - "cross-env": "10.1.0", - "typescript": "5.9.3", - "vite": "8.0.16", "@emotion/babel-plugin": "11.13.5", "@emotion/react": "11.14.0", "@iconify/json": "2.2.484", + "@monaco-editor/react": "4.7.0", "@svgr/core": "8.1.0", "@svgr/plugin-jsx": "8.1.0", - "@tanstack/router-plugin": "1.168.18", + "@tanstack/react-query": "5.90.16", "@tanstack/react-router": "1.170.15", "@tanstack/react-router-devtools": "1.167.0", - "postcss": "8.5.15", - "tailwindcss": "4.1.16", - "unplugin-icons": "23.0.1", - "vite-plugin-sass-dts": "1.3.37", - "sass-embedded": "1.93.3", - "vite-plugin-html": "3.2.2", - "validator": "13.15.35", - "@types/validator": "13.15.10", - "@types/lodash-es": "4.17.12", - "@tanstack/react-query": "5.90.16", + "@tanstack/router-plugin": "1.168.18", + "@tauri-apps/cli": "2.11.2", + "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@types/d3": "7.4.3", "@types/json-schema": "7.0.15", + "@types/less": "3.0.8", + "@types/lodash-es": "4.17.12", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@types/validator": "13.15.10", + "@vitejs/plugin-react": "6.0.2", + "cross-env": "10.1.0", + "filesize": "11.0.17", "meta-json-schema": "1.19.27", - "nanoid": "5.1.11", - "@monaco-editor/react": "4.7.0", "monaco-yaml": "5.4.0", + "nanoid": "5.1.11", + "postcss": "8.5.15", + "sass-embedded": "1.93.3", "shiki": "2.5.0", - "filesize": "11.0.17", - "@types/less": "3.0.8" + "tailwindcss": "4.1.16", + "typescript": "5.9.3", + "unplugin-icons": "23.0.1", + "validator": "13.15.35", + "vite": "8.0.16", + "vite-plugin-html": "3.2.2", + "vite-plugin-sass-dts": "1.3.37" } } diff --git a/frontend/chimera/src/components/app/modules/route-list-item.tsx b/frontend/chimera/src/components/app/modules/route-list-item.tsx index c5e95aaa..a0c097e6 100644 --- a/frontend/chimera/src/components/app/modules/route-list-item.tsx +++ b/frontend/chimera/src/components/app/modules/route-list-item.tsx @@ -76,7 +76,8 @@ export const RouteListItem = ({ ({ color: match ? theme.vars.palette.primary.main : undefined, diff --git a/frontend/chimera/src/components/profiles/profile-dialog.tsx b/frontend/chimera/src/components/profiles/profile-dialog.tsx index fe7f7a3e..e243fa42 100644 --- a/frontend/chimera/src/components/profiles/profile-dialog.tsx +++ b/frontend/chimera/src/components/profiles/profile-dialog.tsx @@ -1,7 +1,6 @@ import { ProfileQueryResultItem, ProfileTemplate, - RemoteProfile, useProfile, useProfileContent, } from '@chimera/interface'; @@ -34,6 +33,8 @@ import { ClashProfile, ClashProfileBuilder } from './utils'; const ProfileMonacoViewer = lazy(() => import('./profile-monaco-viewer')); +type RemoteProfileForm = Extract; + export interface ProfileDialogProps { profile?: ProfileQueryResultItem; open: boolean; @@ -68,13 +69,17 @@ export const ProfileDialog = ({ useForm({ defaultValues: (profile as ClashProfile) || { type: 'remote', + uid: null, name: addProfileCtx?.name || 'New Profile', desc: addProfileCtx?.desc || '', + file: null, + updated: null, url: addProfileCtx?.url || '', + chain: null, + extra: null, option: { - // user_agent: "", - with_proxy: false, - self_proxy: false, + user_agent: null, + update_interval: null, }, }, }); @@ -129,12 +134,12 @@ export const ProfileDialog = ({ const toCreate = async () => { if (isRemote) { - const data = form as RemoteProfile; + const data = form as RemoteProfileForm; await create.mutateAsync({ type: 'url', data: { - url: data.url, + url: data.url ?? '', option: data.option ? { ...data.option, @@ -156,10 +161,16 @@ export const ProfileDialog = ({ const toUpdate = async () => { const value = latestEditor.current.value; + const uid = profile?.uid; + + if (!uid) { + return; + } + await contentFn.upsert.mutateAsync(value); await patch.mutateAsync({ - uid: form.uid!, + uid, profile: form, }); }; diff --git a/frontend/chimera/src/components/profiles/utils.ts b/frontend/chimera/src/components/profiles/utils.ts index aab55190..810e0778 100644 --- a/frontend/chimera/src/components/profiles/utils.ts +++ b/frontend/chimera/src/components/profiles/utils.ts @@ -1,4 +1,7 @@ -import type { Profile, ProfileBuilder } from '@chimera/interface'; +import type { + NormalizedProfileBuilder, + ProfileQueryResultItem, +} from '@chimera/interface'; /** * Filters an array of profiles into two categories: clash and chain profiles. @@ -8,7 +11,7 @@ import type { Profile, ProfileBuilder } from '@chimera/interface'; * - clash: Array of profiles where type is 'remote' or 'local' * - chain: Array of profiles where type is 'merge' or has a script property */ -export function filterProfiles(items?: T[]) { +export function filterProfiles(items?: T[]) { /** * Filters the input array to include only items of type 'remote' or 'local' * @param items - Array of items to filter @@ -36,9 +39,12 @@ export function filterProfiles(items?: T[]) { }; } -export type ClashProfile = Extract; +export type ClashProfile = Extract< + ProfileQueryResultItem, + { type: 'remote' | 'local' } +>; export type ClashProfileBuilder = Extract< - ProfileBuilder, + NormalizedProfileBuilder, { type: 'remote' | 'local' } >; diff --git a/frontend/chimera/src/components/proxies/node-list.tsx b/frontend/chimera/src/components/proxies/node-list.tsx index 31469a8f..7bc2f12d 100644 --- a/frontend/chimera/src/components/proxies/node-list.tsx +++ b/frontend/chimera/src/components/proxies/node-list.tsx @@ -1,6 +1,6 @@ import { + ClashProxiesQueryGroupItem, ClashProxiesQueryProxyItem, - ProxyGroupItem, useClashProxies, useProxyMode, useSetting, @@ -48,7 +48,7 @@ export const NodeList = forwardRef(function NodeList( const proxyGroupSort = useAtomValue(proxyGroupSortAtom); - const [group, setGroup] = useState(); + const [group, setGroup] = useState(); const sortGroup = useCallback(() => { if (!proxyMode.global) { diff --git a/frontend/chimera/src/components/proxies/utils.ts b/frontend/chimera/src/components/proxies/utils.ts index d534c0ee..397cd983 100644 --- a/frontend/chimera/src/components/proxies/utils.ts +++ b/frontend/chimera/src/components/proxies/utils.ts @@ -1,4 +1,7 @@ -import { ProxyGroupItem, ProxyItemHistory } from '@chimera/interface'; +import { + ClashProxiesQueryGroupItem, + ProxyItemHistory, +} from '@chimera/interface'; export const filterDelay = (history?: ProxyItemHistory[]): number => { if (!history || history.length === 0) { @@ -15,10 +18,10 @@ export enum SortType { } export const nodeSortingFn = ( - selectedGroup: ProxyGroupItem, + selectedGroup: ClashProxiesQueryGroupItem, type: SortType, ) => { - let sortedList = selectedGroup.all?.slice(); + let sortedList = selectedGroup.all.slice(); switch (type) { case SortType.Delay: { diff --git a/frontend/chimera/src/components/setting/setting-chimera-misc.tsx b/frontend/chimera/src/components/setting/setting-chimera-misc.tsx index e3b386a0..7c8112c4 100644 --- a/frontend/chimera/src/components/setting/setting-chimera-misc.tsx +++ b/frontend/chimera/src/components/setting/setting-chimera-misc.tsx @@ -1,7 +1,7 @@ import { useSetting, type BreakWhenProxyChange as BreakWhenProxyChangeType, - type LoggingLevel, + type LoggingLevel_Serialize, type ProxiesSelectorMode, } from '@chimera/interface'; import { BaseCard, MenuItem, SwitchItem } from '@chimera/ui'; @@ -28,7 +28,7 @@ const AppLogLevel = () => { label={m.settings_nyanpasu_app_log_level_label()} options={options} selected={value || 'info'} - onSelected={(value) => upsert(value as LoggingLevel)} + onSelected={(value) => upsert(value as LoggingLevel_Serialize)} /> ); }; diff --git a/frontend/chimera/src/components/setting/setting-chimera-ui.tsx b/frontend/chimera/src/components/setting/setting-chimera-ui.tsx index f23329c1..f3818dbc 100644 --- a/frontend/chimera/src/components/setting/setting-chimera-ui.tsx +++ b/frontend/chimera/src/components/setting/setting-chimera-ui.tsx @@ -8,7 +8,9 @@ import { useAtom } from 'jotai'; import { MuiColorInput } from 'mui-color-input'; import { useEffect, useState } from 'react'; import { isHexColor } from 'validator'; +import { useLanguage } from '@/components/providers/language-provider'; import * as m from '@/paraglide/messages'; +import type { Locale } from '@/paraglide/runtime'; import { atomIsDrawerOnlyIcon } from '@/store'; import { languageOptions } from '@/utils/language'; import { DEFAULT_COLOR } from '../layout/use-custom-theme'; @@ -20,15 +22,15 @@ const commonSx = { }; const LanguageSwitch = () => { - const language = useSetting('language'); + const { setLanguage, language: currentLocale } = useLanguage(); return ( language.upsert(value as string)} + selected={currentLocale || 'en'} + onSelected={(value) => setLanguage(value as Locale)} /> ); }; diff --git a/frontend/chimera/src/components/setting/setting-chimera-version.tsx b/frontend/chimera/src/components/setting/setting-chimera-version.tsx index 7deece93..dba9a699 100644 --- a/frontend/chimera/src/components/setting/setting-chimera-version.tsx +++ b/frontend/chimera/src/components/setting/setting-chimera-version.tsx @@ -57,7 +57,7 @@ export const SettingNyanpasuVersion = () => { }); return ( - + { : clashVersion?.version || '-'; }, [clashVersion]); - const changeClashCore = useLockFn(async (core: ClashCore) => { + const changeClashCore = useLockFn(async (core: ClashCore_Serialize) => { try { loading.mask = true; try { @@ -145,9 +145,9 @@ export const SettingClashCore = () => { > changeClashCore(core as ClashCore)} + onClick={() => changeClashCore(core as ClashCore_Serialize)} /> ); diff --git a/frontend/chimera/src/components/ui/dnd-grid/context.ts b/frontend/chimera/src/components/ui/dnd-grid/context.ts new file mode 100644 index 00000000..7a6d87ee --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/context.ts @@ -0,0 +1,64 @@ +/** + * DnD Grid 上下文 + * + * 迁移自 ref: `src/components/ui/dnd-grid/context.ts` + * + * 提供 DndGrid 上下文给子组件(DndGridItem),使其能够: + * - 访问当前显示的网格项列表 + * - 获取项的像素坐标矩形 + * - 读取拖拽放置信息映射 + * - 检查当前激活/调整大小的项 ID + * - 调用调整大小的回调函数 + * - 检查网格是否禁用/只读/覆盖层模式 + */ + +import { createContext, useContext, type RefObject } from 'react'; +import type { + DndGridItemType, + GridItemConstraints, + ItemRect, + ResizeHandle, +} from './types'; + +/** + * DnD Grid 上下文值类型 + * 包含 DndGridItem 渲染所需的所有状态和回调 + */ +const DndGridContext = createContext<{ + displayItems: DndGridItemType[]; + getItemRect: (item: DndGridItemType) => ItemRect; + dropInfoMap: Record; + activeItemId: string | null; + resizingItemId: string | null; + disabled: boolean; + sourceOnly: boolean; + dragIdPrefix: string; + isOverlay: boolean; + constraintsMapRef: RefObject> & { + current: Record; + }; + onResizeStart: ( + id: string, + handle: ResizeHandle, + startX: number, + startY: number, + ) => void; + onResizeMove: (currentX: number, currentY: number) => void; + onResizeEnd: () => void; +} | null>(null); + +export const DndGridProvider = DndGridContext.Provider; + +/** + * 获取 DndGrid 上下文的 Hook + * 必须在 DndGrid 组件内部使用,否则会抛出错误 + */ +export function useDndGridContext() { + const ctx = useContext(DndGridContext); + + if (!ctx) { + throw new Error('DndGridItem must be used inside DndGrid'); + } + + return ctx; +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-item.tsx b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-item.tsx new file mode 100644 index 00000000..3e52ca12 --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-item.tsx @@ -0,0 +1,302 @@ +/** + * DnD Grid 项组件 + * + * 迁移自 ref: `src/components/ui/dnd-grid/dnd-grid-item.tsx` + * + * 职责: + * - 渲染单个网格项,使用 @dnd-kit 的 useDraggable 实现拖拽 + * - 使用 framer-motion 的 useSpring 实现放置后弹性动画 + * - 提供调整大小的拖拽手柄(ResizeKnob) + * - 支持覆盖层模式(DragOverlay 内渲染时跳过定位逻辑) + * + * 两种渲染模式: + * 1. isOverlay: 仅在 DragOverlay 内使用,简单包裹子元素以填充 overlay div + * 2. 普通模式:使用 DndGridItemDraggable 包含完整拖拽和定位逻辑 + */ + +import { cn } from '@chimera/ui'; +import { useDraggable } from '@dnd-kit/core'; +import { + AnimatePresence, + motion, + useSpring, + type Transition, +} from 'framer-motion'; +import { useLayoutEffect, useRef, type PropsWithChildren } from 'react'; +import { useDndGridContext } from './context'; +import type { GridItemConstraints } from './types'; + +/** 位置弹性动画配置 */ +const SPRING_OPTIONS = { + stiffness: 350, + damping: 35, +} as Transition; + +/** 调整大小时过渡动画配置 */ +const RESIZE_SPRING = { + type: 'spring', + stiffness: 400, + damping: 35, +} as Transition; + +/** 普通布局变化的瞬间过渡(无动画) */ +const INSTANT = { + duration: 0, +} as Transition; + +export type DndGridItemProps = PropsWithChildren<{ + id: T; + className?: string; +}> & + GridItemConstraints; + +/** + * 调整大小手柄组件 + * + * 位于项右下角,使用 Pointer Events 实现拖拽 + * 通过 onPointerDown/Move/Up 跟踪拖拽坐标变化 + */ +function ResizeKnob({ + onStart, + onMove, + onEnd, +}: { + onStart: (x: number, y: number) => void; + onMove: (x: number, y: number) => void; + onEnd: () => void; +}) { + return ( + { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + onStart(e.clientX, e.clientY); + }} + onPointerMove={(e) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) { + return; + } + + onMove(e.clientX, e.clientY); + }} + onPointerUp={(e) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) { + return; + } + + e.currentTarget.releasePointerCapture(e.pointerId); + onEnd(); + }} + onPointerCancel={(e) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) { + return; + } + + e.currentTarget.releasePointerCapture(e.pointerId); + onEnd(); + }} + initial={{ + scale: 0.85, + opacity: 0, + }} + animate={{ + scale: 1, + opacity: 1, + }} + exit={{ + scale: 0.85, + opacity: 0, + }} + transition={{ + type: 'tween', + duration: 0.1, + ease: 'easeOut', + }} + > + {/* 右下角拖拽手柄图标 */} + + + + + ); +} + +/** + * 可拖拽的网格项 + * + * 包含完整的定位、拖拽和调整大小逻辑: + * - 使用 useDraggable 注册拖拽 + * - 使用 useSpring 实现放置后回弹动画 + * - 在编辑模式下显示调整大小手柄 + * - 拖拽时自身变为透明,由 DragOverlay 替代显示 + */ +function DndGridItemDraggable({ + id, + className, + children, + minW, + minH, + maxW, + maxH, +}: DndGridItemProps) { + const { + displayItems, + getItemRect, + dropInfoMap, + activeItemId, + resizingItemId, + disabled, + sourceOnly, + dragIdPrefix, + constraintsMapRef, + onResizeStart, + onResizeMove, + onResizeEnd, + } = useDndGridContext(); + + // 在每次渲染时同步约束(确保调整大小前约束始终最新) + constraintsMapRef.current[id] = { minW, minH, maxW, maxH }; + + const item = displayItems.find((i) => i.id === id); + + // 拖拽禁用条件:disabled 或没有任何其他项正在被调整大小 + const { attributes, listeners, setNodeRef } = useDraggable({ + id: dragIdPrefix ? `${dragIdPrefix}${id}` : id, + disabled: disabled || !item || resizingItemId !== null, + data: item, + }); + + // 放置后弹性动画 + const springX = useSpring(0, SPRING_OPTIONS); + const springY = useSpring(0, SPRING_OPTIONS); + + const dropInfo = dropInfoMap[id]; + const prevDropInfoRef = useRef(undefined); + + // 当有新的放置信息时,跳转到放置位置并弹回 + useLayoutEffect(() => { + if (!dropInfo || dropInfo === prevDropInfoRef.current || !item) { + return; + } + + prevDropInfoRef.current = dropInfo; + const rect = getItemRect(item); + springX.jump(dropInfo.left - rect.left); + springY.jump(dropInfo.top - rect.top); + springX.set(0); + springY.set(0); + }, [dropInfo, item, getItemRect, springX, springY]); + + if (!item) { + return null; + } + + const rect = getItemRect(item); + const isActiveItem = activeItemId === id; + const isResizing = resizingItemId === id; + + return ( + + {children} + + {/* 编辑模式下显示调整大小手柄 */} + + {!disabled && !sourceOnly && ( + onResizeStart(id, 'bottom-right', x, y)} + onMove={onResizeMove} + onEnd={onResizeEnd} + /> + )} + + + ); +} + +/** + * DndGridItem 组件 - 入口 + * + * 根据是否为覆盖层模式分派到不同的渲染路径: + * - isOverlay: 直接包裹 children,不执行定位(由 DragOverlay 的样式控制) + * - 普通模式: 使用 DndGridItemDraggable 包含完整功能 + */ +export function DndGridItem({ + id, + className, + children, + minW, + minH, + maxW, + maxH, +}: DndGridItemProps) { + const { isOverlay } = useDndGridContext(); + + if (isOverlay) { + // DragOverlay 内部:跳过定位和拖拽逻辑,只填充 overlay 容器 + return
{children}
; + } + + return ( + + {children} + + ); +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-root.tsx b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-root.tsx new file mode 100644 index 00000000..65223a3d --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid-root.tsx @@ -0,0 +1,231 @@ +/** + * DnD Grid 根组件 + * + * 迁移自 ref: `src/components/ui/dnd-grid/dnd-grid-root.tsx` + * + * 职责: + * - 包装在 DnD 页面最外层(如 Dashboard) + * - 提供 DndContext 和根级上下文 + * - 管理多个 DndGrid 子实例的注册 + * - 处理拖拽事件路由:将 @dnd-kit 事件分发到对应网格的注册处理器 + * + * 多网格支持: + * 当页面上有多个 DndGrid(如 Dashboard 的主网格 + WidgetSheet 的来源网格), + * DndGridRoot 通过 findGrid 查找拖拽项所属的网格,将事件路由到该网格的处理器。 + * 即使来源网格在拖拽中卸载(WidgetSheet 关闭),pendingSourceRef 仍保留 onSourceDrop + * 回调以完成拖拽放置。 + */ + +import { + DndContext, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragMoveEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { useCallback, useRef, useState, type PropsWithChildren } from 'react'; +import { + DndGridRootContext, + type ActiveDrag, + type GridRegistration, +} from './root-context'; + +/** + * 根据拖拽项的 ID 查找其所属的网格注册信息 + * 遍历所有已注册的网格,检查项的 ID 是否匹配(考虑 dragIdPrefix) + */ +function findGrid( + grids: Map, + activeId: string, +): { gridId: string; reg: GridRegistration; plainId: string } | null { + for (const [gridId, reg] of grids) { + const { itemIds, dragIdPrefix } = reg; + + if (dragIdPrefix) { + if (activeId.startsWith(dragIdPrefix)) { + const plain = activeId.slice(dragIdPrefix.length); + + if (itemIds.includes(plain)) { + return { + gridId, + reg, + plainId: plain, + }; + } + } + } else if (itemIds.includes(activeId)) { + return { + gridId, + reg, + plainId: activeId, + }; + } + } + + return null; +} + +/** + * DndGridRoot 组件 + * + * 用法: + * ```tsx + * + * + * {renderWidgets} + * + * + * + * ``` + */ +export function DndGridRoot({ children }: PropsWithChildren) { + const gridsRef = useRef>(new Map()); + + const [activeDrag, setActiveDrag] = useState(null); + + // 拖拽开始时捕获 onSourceDrop,以应对来源网格在拖拽中卸载(例如 WidgetSheet 关闭) + const pendingSourceRef = useRef<{ + onSourceDrop?: (itemId: string) => void; + itemId: string; + } | null>(null); + + const registerGrid = useCallback((gridId: string, reg: GridRegistration) => { + gridsRef.current.set(gridId, reg); + }, []); + + const unregisterGrid = useCallback((gridId: string) => { + gridsRef.current.delete(gridId); + }, []); + + // 触控和鼠标传感器配置 + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 6 }, + }), + ); + + /** + * 拖拽开始处理: + * 1. 查找项所属的网格 + * 2. 如果是来源网格(sourceOnly),捕获 onSourceDrop 并触发 onSourceDragStart + * 3. 否则委派到网格自身的 handleDragStart + * 4. 设置 activeDrag 用于 DragOverlay + */ + const handleDragStart = useCallback((e: DragStartEvent) => { + const activeId = String(e.active.id); + const found = findGrid(gridsRef.current, activeId); + + if (!found) { + return; + } + + const { reg, plainId } = found; + const { sourceOnly, getCellSize, onSourceDragStart } = reg; + + const data = e.active.data.current as + | { w?: number; h?: number } + | undefined; + const { cellW, cellH, gap } = getCellSize(); + const w = data?.w ?? 2; + const h = data?.h ?? 2; + + if (sourceOnly) { + // 在 onSourceDragStart 可能卸载网格之前捕获回调 + pendingSourceRef.current = { + onSourceDrop: reg.onSourceDrop, + itemId: plainId, + }; + onSourceDragStart?.(); + } else { + reg.handleDragStart(e); + } + + setActiveDrag({ + itemId: plainId, + dims: { + width: w * cellW + (w - 1) * gap, + height: h * cellH + (h - 1) * gap, + }, + }); + }, []); + + /** + * 拖拽移动处理: + * 非 sourceOnly 的网格才需要实时更新预览位置 + */ + const handleDragMove = useCallback((e: DragMoveEvent) => { + const activeId = String(e.active.id); + const found = findGrid(gridsRef.current, activeId); + + if (!found) { + return; + } + + const { reg } = found; + + if (!reg.sourceOnly) { + reg.handleDragMove(e); + } + }, []); + + /** + * 拖拽结束处理: + * 1. 如果有 pending source drop,调用 onSourceDrop 并返回 + * 2. 否则委派到网格自身的 handleDragEnd + */ + const handleDragEnd = useCallback((e: DragEndEvent) => { + setActiveDrag(null); + + // 来源拖拽:使用捕获的处理器(网格可能已经卸载) + if (pendingSourceRef.current) { + const { onSourceDrop, itemId } = pendingSourceRef.current; + pendingSourceRef.current = null; + onSourceDrop?.(itemId); + return; + } + + const activeId = String(e.active.id); + const found = findGrid(gridsRef.current, activeId); + + if (!found) { + return; + } + + found.reg.handleDragEnd(e); + }, []); + + /** + * 拖拽取消处理: + * 清空所有状态并通知所有非 sourceOnly 的网格 + */ + const handleDragCancel = useCallback(() => { + pendingSourceRef.current = null; + setActiveDrag(null); + + for (const [, reg] of gridsRef.current) { + if (!reg.sourceOnly) { + reg.handleDragCancel(); + } + } + }, []); + + return ( + + + {children} + + + ); +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/dnd-grid.tsx b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid.tsx new file mode 100644 index 00000000..3302be79 --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/dnd-grid.tsx @@ -0,0 +1,465 @@ +/** + * DnD Grid 组件 + * + * 迁移自 ref: `src/components/ui/dnd-grid/dnd-grid.tsx` + * + * 职责: + * - 提供可拖拽、可调整大小的网格布局 + * - 使用 @dnd-kit 实现拖拽交互 + * - 支持两种模式: + * 1. 独立模式:自身包含 DndContext,适合单网格页面 + * 2. 受管模式:注册到 DndGridRoot,适合多网格共存页面(如 Dashboard + WidgetSheet) + * - 通过 resize 手柄支持项的大小调整 + * - 渲染占位符、拖拽覆盖层和动画过渡 + * + * 核心流程: + * 1. useGridLayout 监听容器尺寸并计算网格布局 + * 2. items 按网格坐标渲染为绝对定位的子元素 + * 3. 拖拽时显示幽灵占位符,目标位置用虚线框预览 + * 4. 放置后通过 onLayoutChange 通知外部更新 + */ + +import { cn } from '@chimera/ui'; +import { + DndContext, + DragOverlay, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragMoveEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { DndGridProvider } from './context'; +import { useDndGridRoot, type GridRegistration } from './root-context'; +import type { + DndGridItemType, + GridItemConstraints, + GridSize, + ResizeHandle, +} from './types'; +import { useGridLayout } from './use-grid-layout'; +import { calculateResize, hasOverlap } from './utils'; + +export interface DndGridProps { + items: DndGridItemType[]; + onLayoutChange?: (items: DndGridItemType[]) => void; + minCellSize?: number; + gap?: number; + size?: GridSize; + onSizeChange?: ( + size: GridSize, + constraintsMap: Record, + ) => void; + children: (item: DndGridItemType) => React.ReactNode; + className?: string; + disabled?: boolean; + sourceOnly?: boolean; + dragIdPrefix?: string; + gridId?: string; + onSourceDrop?: (itemId: string) => void; + onSourceDragStart?: () => void; +} + +export function DndGrid({ + items, + onLayoutChange, + minCellSize = 96, + gap = 8, + size, + onSizeChange, + children, + className, + disabled = true, + sourceOnly = false, + dragIdPrefix = '', + gridId, + onSourceDrop, + onSourceDragStart, +}: DndGridProps) { + const constraintsMapRef = useRef>({}); + + const { containerRef, layout, computedSize, getItemRect, snapToGrid } = + useGridLayout(minCellSize, gap, size); + + const onSizeChangeRef = useRef(onSizeChange); + onSizeChangeRef.current = onSizeChange; + + // 当网格尺寸变化时通知外部(用于 Dashboard 布局自适应) + useEffect(() => { + if (computedSize) { + onSizeChangeRef.current?.(computedSize, constraintsMapRef.current); + } + }, [computedSize]); + + const [activeItem, setActiveItem] = useState | null>(null); + const [previewItem, setPreviewItem] = useState | null>( + null, + ); + + const [displayItems, setDisplayItems] = useState[]>(items); + const [dropInfoMap, setDropInfoMap] = useState< + Record + >({}); + + const isDragging = useRef(false); + const lastValidSnapRef = useRef | null>(null); + + // 调整大小状态 + const resizeStateRef = useRef<{ + id: string; + handle: ResizeHandle; + startItem: DndGridItemType; + startX: number; + startY: number; + } | null>(null); + const resizePreviewRef = useRef | null>(null); + + const [resizingItemId, setResizingItemId] = useState(null); + const [resizePreview, setResizePreview] = useState | null>( + null, + ); + + // 当外部 items 变化时同步到内部状态(仅在非拖拽状态时) + useEffect(() => { + if (!isDragging.current && !resizeStateRef.current) { + setDisplayItems(items); + } + }, [items]); + + // 调整大小时的预览替换 + const effectiveDisplayItems = resizePreview + ? displayItems.map((item) => + item.id === resizePreview.id ? resizePreview : item, + ) + : displayItems; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 6 }, + }), + ); + + /** 拖拽开始:记录拖拽项并清空重叠状态 */ + const handleDragStart = useCallback( + ({ active }: DragStartEvent) => { + const item = items.find((i) => i.id === active.id); + + if (!item) { + return; + } + + isDragging.current = true; + lastValidSnapRef.current = item; + setActiveItem(item); + setPreviewItem(item); + setDisplayItems(items); + }, + [items], + ); + + /** 拖拽移动:计算网格对齐位置,检测重叠 */ + const handleDragMove = useCallback( + ({ delta }: DragMoveEvent) => { + if (!activeItem) { + return; + } + + const snapped = snapToGrid(activeItem, delta.x, delta.y); + + // 仅在目标位置空闲时更新占位符 + if (!hasOverlap(items, activeItem.id, snapped)) { + lastValidSnapRef.current = snapped; + setPreviewItem(snapped); + } + // 重叠时占位符保持在 lastValidSnapRef,无需更新状态 + }, + [activeItem, items, snapToGrid], + ); + + /** 拖拽结束:确定最终位置,通知外部 */ + const handleDragEnd = useCallback( + ({ active, delta }: DragEndEvent) => { + if (!activeItem) { + return; + } + + const originalRect = getItemRect(activeItem); + const dropLeft = originalRect.left + delta.x; + const dropTop = originalRect.top + delta.y; + + const snapped = snapToGrid(activeItem, delta.x, delta.y); + // 如果对齐位置空闲则使用,否则回退到最后一个有效位置 + const finalItem = !hasOverlap(items, activeItem.id, snapped) + ? snapped + : (lastValidSnapRef.current ?? activeItem); + + const newItems = items.map((i) => (i.id === active.id ? finalItem : i)); + const id = String(active.id); + + isDragging.current = false; + lastValidSnapRef.current = null; + + setActiveItem(null); + setPreviewItem(null); + setDisplayItems(newItems); + setDropInfoMap((prev) => ({ + ...prev, + [id]: { left: dropLeft, top: dropTop }, + })); + onLayoutChange?.(newItems); + }, + [activeItem, items, snapToGrid, getItemRect, onLayoutChange], + ); + + /** 拖拽取消:恢复原始状态 */ + const handleDragCancel = useCallback(() => { + isDragging.current = false; + lastValidSnapRef.current = null; + setActiveItem(null); + setPreviewItem(null); + setDisplayItems(items); + }, [items]); + + /** 调整大小开始 */ + const onResizeStart = useCallback( + (id: string, handle: ResizeHandle, startX: number, startY: number) => { + const item = items.find((i) => i.id === id); + + if (!item || disabled) { + return; + } + + resizeStateRef.current = { + id, + handle, + startItem: item, + startX, + startY, + }; + + setResizingItemId(id); + }, + [items, disabled], + ); + + /** 调整大小移动 */ + const onResizeMove = useCallback( + (currentX: number, currentY: number) => { + const state = resizeStateRef.current; + + if (!state) { + return; + } + + const { cellW, cellH, cols, rows } = layout; + const deltaX = currentX - state.startX; + const deltaY = currentY - state.startY; + const candidate = calculateResize( + state.startItem, + state.handle, + deltaX, + deltaY, + cellW, + cellH, + gap, + cols, + rows, + constraintsMapRef.current[state.id], + ); + + if (!hasOverlap(items, state.id, candidate)) { + resizePreviewRef.current = candidate; + setResizePreview(candidate); + } + }, + [items, layout, gap], + ); + + /** 调整大小结束 */ + const onResizeEnd = useCallback(() => { + const preview = resizePreviewRef.current; + resizeStateRef.current = null; + resizePreviewRef.current = null; + setResizingItemId(null); + setResizePreview(null); + + if (preview) { + const newItems = items.map((i) => (i.id === preview.id ? preview : i)); + setDisplayItems(newItems); + onLayoutChange?.(newItems); + } + }, [items, onLayoutChange]); + + const rootCtx = useDndGridRoot(); + + // 稳定引用对象,每次渲染时更新属性(用于 DndGridRoot 注册) + // 这样 root 始终读取最新的回调闭包,而引用不变避免重复 effect + const registrationRef = useRef({ + itemIds: [], + dragIdPrefix: '', + sourceOnly: false, + handleDragStart: () => {}, + handleDragMove: () => {}, + handleDragEnd: () => {}, + handleDragCancel: () => {}, + getCellSize: () => ({ cellW: 0, cellH: 0, gap: 0 }), + }); + + Object.assign(registrationRef.current, { + itemIds: items.map((i) => i.id), + dragIdPrefix, + sourceOnly, + handleDragStart, + handleDragMove, + handleDragEnd, + handleDragCancel, + getCellSize: () => ({ cellW: layout.cellW, cellH: layout.cellH, gap }), + onSourceDrop, + onSourceDragStart, + }); + + // 注册到 DndGridRoot(仅在受管模式下) + useLayoutEffect(() => { + if (!rootCtx || !gridId) { + return; + } + + rootCtx.registerGrid(gridId, registrationRef.current); + + return () => { + rootCtx.unregisterGrid(gridId); + }; + }, [rootCtx, gridId]); + + const isManaged = Boolean(rootCtx && gridId); + + const overlayRect = activeItem ? getItemRect(activeItem) : null; + const placeholderRect = previewItem ? getItemRect(previewItem) : null; + + // 网格内容:提供 DndGridProvider + 容器 + 占位符 + 项渲染 + 覆盖层 + const gridContent = ( + +
+ {/* 拖拽占位符虚线框 */} + + {placeholderRect && activeItem && ( + + )} + + + {/* 渲染所有网格项 */} + {effectiveDisplayItems.map((item) => ( + {children(item)} + ))} +
+ + {/* 独立模式(非受管)下的 DragOverlay */} + {!isManaged && ( + + + {activeItem && overlayRect && ( + + + {children(activeItem)} + + + )} + + + )} +
+ ); + + // 受管模式:gridContent 不包含 DndContext(由 DndGridRoot 提供) + if (isManaged) { + return gridContent; + } + + // 独立模式:包裹 DndContext + return ( + + {gridContent} + + ); +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/index.ts b/frontend/chimera/src/components/ui/dnd-grid/index.ts new file mode 100644 index 00000000..7751bfa9 --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/index.ts @@ -0,0 +1,19 @@ +/** + * DnD Grid 导出入口 + * + * 迁移自 ref: `src/components/ui/dnd-grid/index.ts` + * + * 导出所有公共组件、Hook 和类型 + */ + +export { DndGrid, type DndGridProps } from './dnd-grid'; +export { DndGridItem, type DndGridItemProps } from './dnd-grid-item'; +export { DndGridProvider } from './context'; +export { useDndGridRoot, type ActiveDrag } from './root-context'; +export { DndGridRoot } from './dnd-grid-root'; +export type { + DndGridItemType, + GridItemConstraints, + GridSize, + ResizeHandle, +} from './types'; diff --git a/frontend/chimera/src/components/ui/dnd-grid/root-context.ts b/frontend/chimera/src/components/ui/dnd-grid/root-context.ts new file mode 100644 index 00000000..f66d8e6b --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/root-context.ts @@ -0,0 +1,63 @@ +/** + * DnD Grid 根上下文 + * + * 迁移自 ref: `src/components/ui/dnd-grid/root-context.ts` + * + * 提供 DndGridRoot 级别的上下文,用于: + * - 注册/注销子 DndGrid(支持多 DndGrid 共存) + * - 跟踪当前拖拽激活项的信息(用于 DragOverlay) + * + * 设计意图: + * 当页面上有多个 DndGrid(例如 Dashboard 的"主网格"和 WidgetSheet 的"来源网格") + * 时,DndGridRoot 管理所有网格的注册,确保拖拽事件正确路由到对应的网格处理器。 + * 拖拽激活后,即使来源网格卸载,DndGridRoot 仍持有拖拽状态和 onSourceDrop 回调。 + */ + +import type { + DragEndEvent, + DragMoveEvent, + DragStartEvent, +} from '@dnd-kit/core'; +import { createContext, useContext } from 'react'; + +/** + * 网格在 DndGridRoot 中的注册信息 + * 每个 DndGrid 实例通过 registerGrid 注册自己的回调 + */ +export type GridRegistration = { + itemIds: string[]; + dragIdPrefix: string; + sourceOnly: boolean; + handleDragStart: (e: DragStartEvent) => void; + handleDragMove: (e: DragMoveEvent) => void; + handleDragEnd: (e: DragEndEvent) => void; + handleDragCancel: () => void; + getCellSize: () => { cellW: number; cellH: number; gap: number }; + onSourceDrop?: (itemId: string) => void; + onSourceDragStart?: () => void; +}; + +/** 当前拖拽激活项的尺寸信息(用于 DragOverlay) */ +export type ActiveDrag = { + itemId: string; + dims: { width: number; height: number }; +}; + +/** DndGridRoot 上下文值 */ +export type DndGridRootContextValue = { + registerGrid: (gridId: string, reg: GridRegistration) => void; + unregisterGrid: (gridId: string) => void; + activeDrag: ActiveDrag | null; +}; + +export const DndGridRootContext = createContext( + null, +); + +/** + * 获取 DndGridRoot 上下文的 Hook + * 返回 registerGrid、unregisterGrid 和 activeDrag + */ +export function useDndGridRoot() { + return useContext(DndGridRootContext); +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/types.ts b/frontend/chimera/src/components/ui/dnd-grid/types.ts new file mode 100644 index 00000000..aea287f7 --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/types.ts @@ -0,0 +1,71 @@ +/** + * DnD Grid 类型定义 + * + * 迁移自 ref: `src/components/ui/dnd-grid/types.ts` + * + * 定义拖拽网格系统的核心类型: + * - DndGridItemType: 网格中每个项的位置和尺寸 + * - GridItemConstraints: 项的最小/最大尺寸约束 + * - GridSize: 网格的列数和行数 + * - GridLayout: 网格的完整布局(含单元格尺寸) + * - ItemRect: 项的像素坐标矩形 + * - ResizeHandle: 调整大小的拖拽手柄方向 + */ + +export type ResizeHandle = + | 'top' + | 'top-right' + | 'right' + | 'bottom-right' + | 'bottom' + | 'bottom-left' + | 'left' + | 'top-left'; + +/** + * DnD 网格项的通用类型 + * @typeParam T - 项 ID 的类型(默认 string) + */ +export type DndGridItemType = { + id: T; + /** 列起始索引(从 0 开始) */ + x: number; + /** 行起始索引(从 0 开始) */ + y: number; + /** 宽度(占网格单元数) */ + w: number; + /** 高度(占网格单元数) */ + h: number; +}; + +/** 网格项的尺寸约束 */ +export type GridItemConstraints = { + /** 最小宽度(网格单元数,默认 1) */ + minW?: number; + /** 最小高度(网格单元数,默认 1) */ + minH?: number; + /** 最大宽度(网格单元数) */ + maxW?: number; + /** 最大高度(网格单元数) */ + maxH?: number; +}; + +/** 网格尺寸:列数和行数 */ +export interface GridSize { + cols: number; + rows: number; +} + +/** 网格完整布局,包含计算后的单元格像素尺寸 */ +export interface GridLayout extends GridSize { + cellW: number; + cellH: number; +} + +/** 项的像素坐标矩形 */ +export interface ItemRect { + left: number; + top: number; + width: number; + height: number; +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/use-grid-layout.ts b/frontend/chimera/src/components/ui/dnd-grid/use-grid-layout.ts new file mode 100644 index 00000000..33d83c3d --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/use-grid-layout.ts @@ -0,0 +1,148 @@ +/** + * DnD Grid 布局计算 Hook + * + * 迁移自 ref: `src/components/ui/dnd-grid/use-grid-layout.ts` + * + * 职责: + * - 使用 ResizeObserver 监听容器尺寸变化 + * - 计算网格的列数/行数、单元格像素尺寸 + * - 提供 getItemRect(项→像素坐标)和 snapToGrid(像素→网格对齐)函数 + * + * 外部可通过 `size` 参数强制指定网格尺寸(相当于 CSS Grid 的 grid-template-columns), + * 用于 WidgetSheet 等需要预览效果的场景。 + */ + +import { isEqual } from 'lodash-es'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { DndGridItemType, GridLayout, GridSize, ItemRect } from './types'; + +/** + * 网格布局 Hook + * + * @param minCellSize - 最小单元格像素尺寸 + * @param gap - 网格间距(像素) + * @param size - 可选的外部指定网格尺寸(覆盖自动计算) + */ +export function useGridLayout( + minCellSize: number, + gap: number, + size?: GridSize, +) { + const containerRef = useRef(null); + + const [layout, setLayout] = useState({ + cols: 1, + rows: 1, + cellW: minCellSize, + cellH: minCellSize, + }); + + const [computedSize, setComputedSize] = useState(null); + + // 跟踪容器尺寸,以便在外部 `size` 变化时重新计算 cellW/cellH + const containerSizeRef = useRef({ width: 0, height: 0 }); + const lastComputedSizeRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + + const recalculate = (width: number, height: number) => { + if (width <= 0 || height <= 0) { + return; + } + + containerSizeRef.current = { width, height }; + + // 计算可容纳的单元格数量:`n * size + (n-1) * gap <= total` + // => n <= (total + gap) / (size + gap) + const computedCols = Math.max( + 1, + Math.floor((width + gap) / (minCellSize + gap)), + ); + const computedRows = Math.max( + 1, + Math.floor((height + gap) / (minCellSize + gap)), + ); + + const newComputedSize = { + cols: computedCols, + rows: computedRows, + }; + + // 避免重复触发不必要的 setState + if (!isEqual(newComputedSize, lastComputedSizeRef.current)) { + lastComputedSizeRef.current = newComputedSize; + setComputedSize(newComputedSize); + } + + // 如果外部指定了 size,使用外部的;否则使用自动计算的 + const cols = size?.cols ?? computedCols; + const rows = size?.rows ?? computedRows; + const cellW = (width - gap * (cols - 1)) / cols; + const cellH = (height - gap * (rows - 1)) / rows; + + const nextLayout = { cols, rows, cellW, cellH }; + // 引用相等性检查,避免无限重渲染循环 + setLayout((prev) => (isEqual(prev, nextLayout) ? prev : nextLayout)); + }; + + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + recalculate(width, height); + }); + + observer.observe(el); + + // 初始计算 + const { width, height } = el.getBoundingClientRect(); + recalculate(width, height); + + return () => { + observer.disconnect(); + }; + }, [minCellSize, gap, size?.cols, size?.rows]); + + /** + * 根据网格项的位置尺寸计算其在容器中的像素矩形 + */ + const getItemRect = useCallback( + (item: DndGridItemType): ItemRect => { + const { cellW, cellH } = layout; + return { + left: item.x * (cellW + gap), + top: item.y * (cellH + gap), + width: item.w * cellW + (item.w - 1) * gap, + height: item.h * cellH + (item.h - 1) * gap, + }; + }, + [layout, gap], + ); + + /** + * 将像素偏移转换为网格对齐后的新位置 + * 确保项不会超出网格边界 + */ + const snapToGrid = useCallback( + ( + item: DndGridItemType, + deltaX: number, + deltaY: number, + ): DndGridItemType => { + const { cellW, cellH, cols, rows } = layout; + const deltaCols = Math.round(deltaX / (cellW + gap)); + const deltaRows = Math.round(deltaY / (cellH + gap)); + + return { + ...item, + x: Math.max(0, Math.min(cols - item.w, item.x + deltaCols)), + y: Math.max(0, Math.min(rows - item.h, item.y + deltaRows)), + }; + }, + [layout, gap], + ); + + return { containerRef, layout, computedSize, getItemRect, snapToGrid }; +} diff --git a/frontend/chimera/src/components/ui/dnd-grid/utils.ts b/frontend/chimera/src/components/ui/dnd-grid/utils.ts new file mode 100644 index 00000000..4bf84787 --- /dev/null +++ b/frontend/chimera/src/components/ui/dnd-grid/utils.ts @@ -0,0 +1,126 @@ +/** + * DnD Grid 工具函数 + * + * 迁移自 ref: `src/components/ui/dnd-grid/utils.ts` + * + * 提供网格布局相关的工具函数: + * - isOverlap: 判断两个网格项是否重叠 + * - hasOverlap: 判断一个候选位置是否与现有项重叠(排除自身) + * - calculateResize: 根据拖拽手柄方向计算调整大小后的新位置 + */ + +import type { + DndGridItemType, + GridItemConstraints, + ResizeHandle, +} from './types'; + +/** + * 判断两个网格项是否重叠(边界比较) + * 使用 AABB(轴对齐边界框)碰撞检测 + */ +export function isOverlap( + a: DndGridItemType, + b: DndGridItemType, +): boolean { + return ( + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + ); +} + +/** + * 判断候选位置是否与现有项列表重叠(排除自身) + * @param items - 现有项列表 + * @param movingId - 正在移动的项 ID(将被排除) + * @param candidate - 候选位置 + * @returns 是否与任何其他项重叠 + */ +export function hasOverlap( + items: DndGridItemType[], + movingId: string, + candidate: DndGridItemType, +): boolean { + return items.some( + (item) => item.id !== movingId && isOverlap(candidate, item), + ); +} + +/** + * 根据拖拽手柄方向计算调整大小后的项位置 + * + * 逻辑: + * - 右侧手柄:增加/减少宽度(受 maxW/cols 约束) + * - 底部手柄:增加/减少高度(受 maxH/rows 约束) + * - 左侧手柄:调整 x 位置并反向调整宽度 + * - 顶部手柄:调整 y 位置并反向调整高度 + * + * @param startItem - 调整前的项 + * @param handle - 拖拽手柄方向 + * @param deltaX - 水平像素偏移(屏幕坐标系) + * @param deltaY - 垂直像素偏移(屏幕坐标系) + * @param cellW - 单元格像素宽度 + * @param cellH - 单元格像素高度 + * @param gap - 网格间距 + * @param cols - 网格列数 + * @param rows - 网格行数 + * @param constraints - 项的尺寸约束 + */ +export function calculateResize( + startItem: DndGridItemType, + handle: ResizeHandle, + deltaX: number, + deltaY: number, + cellW: number, + cellH: number, + gap: number, + cols: number, + rows: number, + constraints: GridItemConstraints = {}, +): DndGridItemType { + const minW = constraints.minW ?? 1; + const minH = constraints.minH ?? 1; + const maxW = constraints.maxW ?? cols; + const maxH = constraints.maxH ?? rows; + + // 将像素偏移转换为网格单元偏移 + const stepX = cellW + gap; + const stepY = cellH + gap; + const deltaCols = Math.round(deltaX / stepX); + const deltaRows = Math.round(deltaY / stepY); + + let { x, y, w, h } = startItem; + + // 右侧拖拽:调整宽度 + if (handle.includes('right')) { + w = Math.max(minW, Math.min(maxW, cols - x, w + deltaCols)); + } + + // 底部拖拽:调整高度 + if (handle.includes('bottom')) { + h = Math.max(minH, Math.min(maxH, rows - y, h + deltaRows)); + } + + // 左侧拖拽:调整 x 位置并反向调整宽度 + if (handle.includes('left')) { + const newX = Math.max(0, Math.min(x + w - minW, x + deltaCols)); + const newW = Math.min(maxW, w + (x - newX)); + x = newX + (w + (x - newX) - newW); + w = newW; + } + + // 顶部拖拽:调整 y 位置并反向调整高度 + if (handle.includes('top')) { + const newY = Math.max(0, Math.min(y + h - minH, y + deltaRows)); + const newH = Math.min(maxH, h + (y - newY)); + y = newY + (h + (y - newY) - newH); + h = newH; + } + + return { + ...startItem, + x, + y, + w, + h, + }; +} diff --git a/frontend/chimera/src/components/ui/sparkline.tsx b/frontend/chimera/src/components/ui/sparkline.tsx new file mode 100644 index 00000000..b058303e --- /dev/null +++ b/frontend/chimera/src/components/ui/sparkline.tsx @@ -0,0 +1,283 @@ +/** + * Sparkline 迷你趋势图组件 + * + * 迁移自 ref: `src/components/ui/sparkline.tsx` + * + * 职责: + * - 使用 D3.js 渲染平滑的迷你趋势曲线(带面积填充) + * - 支持数据追加动画:旧数据滑出左侧,新数据切入右侧 + * - 智能 y 轴缩放:稳定数据占上方 1/3,波动数据占满高度 + * - 使用 Catmull-Rom 样条曲线生成平滑线条 + * - 护点(guard points)技术消除端点切线抖动 + * + * 动画机制: + * 1. SVG translateX 实现水平滚动(线性缓动,恒定速度) + * 2. D3 easeCubicInOut 实现 yMax 插值(非线性缓动,自然感) + * 3. 二者解耦,互不影响 + * 4. 使用 framer-motion animate 进行高性能动画驱动 + */ + +import { cn } from '@chimera/ui'; +import * as d3 from 'd3'; +import { animate } from 'framer-motion'; +import { cloneDeep } from 'lodash-es'; +import { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'; + +/** + * 变异系数(标准差/均值)阈值 + * 低于此阈值认为序列"稳定",图表只占 SVG 高度的下方 1/3 + */ +const STABLE_CV_THRESHOLD = 0.15; + +/** + * 稳定序列时,图表占用 SVG 高度的比例 + * topFactor = 2/3 → 可用波段 = height - height * (2/3) = h/3 + */ +const STABLE_TOP_FACTOR = 2 / 3; + +/** 波动序列时,图表占用 SVG 高度的比例 */ +const ACTIVE_TOP_FACTOR = 0.35; + +export const Sparkline = ({ + data, + animationDuration = 1, + className, + ...props +}: ComponentPropsWithoutRef<'svg'> & { + data: number[]; + animationDuration?: number; +}) => { + const svgRef = useRef(null); + const gRef = useRef(null); + const prevDataRef = useRef(null); + // 最近一次滚动到左侧之外的点值,用于保持 x=0 处的曲线连续 + const leftGuardRef = useRef(null); + const animRef = useRef | null>(null); + + useEffect(() => { + if (!svgRef.current || !gRef.current) { + return; + } + + const g = d3.select(gRef.current); + const { width, height } = svgRef.current.getBoundingClientRect(); + + if (!width || !height) { + return; + } + + /** + * 生成折线和面积路径字符串 + */ + const makePaths = ( + points: number[], + xRange: [number, number], + yMax: number, + ) => { + const mean = d3.mean(points) ?? 0; + const std = d3.deviation(points) ?? 0; + const cv = mean > 0 ? std / mean : 0; + // 稳定序列只占底部 1/3,波动序列占满 + const topFactor = + yMax === 0 + ? 1 + : cv < STABLE_CV_THRESHOLD + ? STABLE_TOP_FACTOR + : ACTIVE_TOP_FACTOR; + + const x = d3 + .scaleLinear() + .domain([0, points.length - 1]) + .range(xRange); + const y = d3 + .scaleLinear() + .domain([0, yMax]) + .range([height, height * topFactor]); + + // Catmull-Rom 样条(alpha=0.5)生成平滑曲线 + const lineGen = d3 + .line() + .x((_, i) => x(i)) + .y((d) => y(d)) + .curve(d3.curveCatmullRom.alpha(0.5)); + + const areaGen = d3 + .area() + .x((_, i) => x(i)) + .y0(height) + .y1((d) => y(d)) + .curve(d3.curveCatmullRom.alpha(0.5)); + + return { + line: lineGen(points) ?? '', + area: areaGen(points) ?? '', + }; + }; + + /** + * 构建带护点的路径 + * + * 护点(guard points)技术: + * 在数据序列两端各添加一个虚拟点(线性外推),使所有真实数据点成为样条曲线的 + * 内部节点,消除端点切向不连续性导致的边界抖动。 + * SVG overflow:hidden 自动裁剪护点区域。 + */ + const buildPaths = ( + points: number[], + xRange: [number, number], + yMax: number, + step: number, + leftGuard?: number, + ) => { + const n = points.length; + + // 空数据或单点数据直接返回 + if (n === 0) { + return { line: '', area: '' }; + } + + if (n === 1) { + return makePaths(points, xRange, yMax); + } + + // 左侧护点:线性外推(2 * p0 - p1) + // 右侧护点:线性外推(2 * pn-1 - pn-2) + const lGuard = leftGuard ?? 2 * points[0] - points[1]; + const rGuard = 2 * points[n - 1] - points[n - 2]; + + return makePaths( + [lGuard, ...points, rGuard], + [xRange[0] - step, xRange[1] + step], + yMax, + ); + }; + + const prevData = prevDataRef.current; + prevDataRef.current = cloneDeep(data); + + // 停止正在进行的动画 + animRef.current?.stop(); + animRef.current = null; + + // 短数据序列(<2 点)直接渲染,无动画 + if (data.length < 2) { + g.selectAll('*').remove(); + g.attr('transform', 'translate(0,0)'); + leftGuardRef.current = null; + return; + } + + if (!prevData || prevData.length !== data.length) { + // 初次渲染或数据长度变化:直接绘制,无动画 + const yMax = Math.max(d3.max(data) ?? 0, 1); + const step = width / (data.length - 1); + const { line, area } = buildPaths( + data, + [0, width], + yMax, + step, + leftGuardRef.current ?? undefined, + ); + + g.selectAll('*').remove(); + g.attr('transform', 'translate(0,0)'); + g.append('path').attr('class', 'area fill-primary/10').attr('d', area); + g.append('path') + .attr('class', 'line stroke-primary') + .attr('fill', 'none') + .attr('stroke-width', 2) + .attr('d', line); + return; + } + + // 数据追加动画: + // N+1 个点 = 旧数据首点(即将滑出)+ 完整新数据集 + const stepWidth = width / (data.length - 1); + const extPoints = [...prevData, data[data.length - 1]]; + const fromYMax = Math.max(d3.max(extPoints) ?? 0, 1); + const toYMax = Math.max(d3.max(data) ?? 0, 1); + const yMaxChanges = Math.abs(fromYMax - toYMax) > 1; + + const leftGuard = leftGuardRef.current ?? undefined; + + // 渲染动画初始状态(N+1 点路径) + const { line: initLine, area: initArea } = buildPaths( + extPoints, + [0, width + stepWidth], + fromYMax, + stepWidth, + leftGuard, + ); + + g.attr('transform', 'translate(0,0)'); + g.select('.area').attr('d', initArea); + g.select('.line').attr('d', initLine); + + let cancelled = false; + + // 使用 framer-motion animate 驱动动画 + const anim = animate(0, 1, { + duration: animationDuration, + ease: 'linear', + onUpdate(t) { + // X 轴:线性平移(恒定速度滑动) + g.attr('transform', `translate(${-stepWidth * t},0)`); + + // Y 轴:使用缓动函数插值 yMax + if (yMaxChanges) { + const easedT = d3.easeCubicInOut(t); + const currentYMax = fromYMax + (toYMax - fromYMax) * easedT; + const { line, area } = buildPaths( + extPoints, + [0, width + stepWidth], + currentYMax, + stepWidth, + leftGuard, + ); + + g.select('.area').attr('d', area); + g.select('.line').attr('d', line); + } + }, + onComplete() { + if (cancelled) { + return; + } + + // 保存滑出的点作为下一个周期的左护点 + leftGuardRef.current = prevData[0]; + + // 动画完成后切换到 N 点路径(无缝切换,因为 N+1 路径 t=1 和 N 路径重合) + const { line, area } = buildPaths( + data, + [0, width], + toYMax, + stepWidth, + prevData[0], + ); + + g.attr('transform', 'translate(0,0)'); + g.select('.area').attr('d', area); + g.select('.line').attr('d', line); + }, + }); + + animRef.current = anim; + + return () => { + cancelled = true; + anim.stop(); + }; + }, [data, animationDuration]); + + return ( + + + + ); +}; diff --git a/frontend/chimera/src/pages/(main)/_modules/-navbar.tsx b/frontend/chimera/src/pages/(main)/_modules/-navbar.tsx index a28041f2..466f704e 100644 --- a/frontend/chimera/src/pages/(main)/_modules/-navbar.tsx +++ b/frontend/chimera/src/pages/(main)/_modules/-navbar.tsx @@ -104,45 +104,62 @@ export default function Navbar({ className, ...props }: ComponentProps<'div'>) { } }); + /** + * 导航栏项目列表 + * + * 迁移自 ref: `src/pages/(main)/_modules/navbar.tsx` + * + * 路由映射: + * - Dashboard → `/main/dashboard`(着陆页) + * - Proxies → `/main/proxies`(已迁移) + * - Profiles → `/main/profiles`(已迁移) + * - Connections → `/main/connections`(已迁移) + * - Rules → `/main/rules`(已迁移) + * - Logs → `/main/logs`(已迁移) + * - Settings → `/main/settings`(已迁移) + * - Providers → `/main/providers`(已迁移) + * + * TODO: 页面迁移完成后,移除到 legacy UI 的切换按钮 + */ const navItems = [ { - to: '/main', - activeWhen: '/main', + to: '/main/dashboard', + activeWhen: '/main/dashboard', label: 'Dashboard', icon: , }, { - to: '/main', + to: '/main/proxies', activeWhen: '/main/proxies', label: 'Proxies', icon: , }, { - to: '/main', + to: '/main/profiles', activeWhen: '/main/profiles', label: 'Profiles', icon: , }, { - to: '/main', + to: '/main/connections', activeWhen: '/main/connections', label: 'Connections', icon: , }, { - to: '/main', + to: '/main/rules', activeWhen: '/main/rules', label: 'Rules', icon: , }, { - to: '/main', + to: '/main/logs', activeWhen: '/main/logs', label: 'Logs', icon: , }, { - to: '/main', + to: '/main/settings', activeWhen: '/main/settings', label: 'Settings', icon: , diff --git a/frontend/chimera/src/pages/(main)/main/connections/_modules/table-row.tsx b/frontend/chimera/src/pages/(main)/main/connections/_modules/table-row.tsx new file mode 100644 index 00000000..d8b1de4a --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/connections/_modules/table-row.tsx @@ -0,0 +1,282 @@ +/** + * 连接表格行组件 + * + * 迁移自 ref: `src/pages/(main)/main/connections/_modules/table-row.tsx` + * + * 职责: + * - 渲染连接表格的每一行 + * - 支持双击查看连接详情(Modal) + * - 提供右键上下文菜单(查看详情 / 关闭连接) + * + * 当前阶段(Connections Step 2 - 迁移至 ref 实现): + * - 使用 @tanstack/react-table + react-virtual 替代 MRT + * - 添加连接详情对话框(双击/右键菜单触发) + * - 添加右键关闭连接功能 + * + * 后续: + * - 连接详情中可添加更多可视化信息(流量折线图等) + */ + +import { useClashConnections } from '@chimera/interface'; +import { BaseDialog, cn } from '@chimera/ui'; +import { Close, InfoOutlined } from '@mui/icons-material'; +import { Button, List, ListItem, Menu, MenuItem } from '@mui/material'; +import { useLockFn } from 'ahooks'; +import { sentenceCase } from 'change-case'; +import dayjs from 'dayjs'; +import { filesize } from 'filesize'; +import { + ComponentProps, + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import * as m from '@/paraglide/messages'; +import type { ConnectionRow } from '../index'; + +/** + * 连接详情对话框 - 在双击行或右键菜单「查看详情」时打开 + * 使用 MUI BaseDialog 实现,与 Chimera 现有对话框模式一致 + */ +function ConnectionDetailDialog({ + data, + open, + onClose, +}: { + data: ConnectionRow; + open: boolean; + onClose: () => void; +}) { + const { deleteConnections } = useClashConnections(); + + /** + * 关闭当前连接 + * 先关闭对话框再执行删除,避免删除缓慢时显示过期数据 + */ + const handleCloseConnection = useLockFn(async () => { + onClose(); + await deleteConnections.mutateAsync(data.id); + }); + + return ( + + + {Object.entries(data) + .filter( + ([key, value]) => + key !== 'metadata' && + !(['closed', 'downloadSpeed', 'uploadSpeed'] as const).includes( + key as never, + ) && + value !== undefined && + value !== null && + value !== '', + ) + .map(([key, value]) => ( + + ))} + + +
Metadata
+
+ + {Object.entries(data.metadata) + .filter( + ([, value]) => + value !== undefined && value !== null && value !== '', + ) + .map(([key, value]) => ( + + ))} +
+ +
+ + +
+
+ ); +} + +function DetailListItem({ label, value }: { label: string; value: unknown }) { + const mono = + label === 'id' || + label.includes('IP') || + label.includes('Port') || + label.includes('Destination') || + label.includes('Source'); + + return ( + +
+
+ {sentenceCase(label)} +
+
+ {formatDetailValue(label, value)} +
+
+
+ ); +} + +/** + * 格式化详情对话框中的值 + * 对 speed 字段使用 filesize,对日期字段使用 dayjs.fromNow + */ +function formatDetailValue(key: string, value: unknown): ReactNode { + if (Array.isArray(value)) { + return {value.join(' / ')}; + } + + const k = key.toLowerCase(); + + if (k.includes('speed')) { + return {filesize(value as number)}/s; + } + + if (k.includes('download') || k.includes('upload')) { + return {filesize(value as number)}; + } + + if (k.includes('port') || k === 'id' || k.includes('ip')) { + return {String(value)}; + } + + // 尝试解析日期字符串 + if ( + typeof value === 'string' && + value.includes('T') && + dayjs(value).isValid() + ) { + return ( + + {dayjs(value).fromNow()} + + ); + } + + return {String(value)}; +} + +/** + * 连接表格行组件 + * + * 提供: + * - 双击打开连接详情 + * - 右键上下文菜单(查看详情 / 关闭连接) + * + * 使用方式与 ref 一致: + * 1. 通过 RegisterContextMenu 配合右键菜单 + * 2. 通过 onDoubleClick 打开详情对话框 + */ +export default function TableRow({ + data, + ...props +}: ComponentProps<'tr'> & { + data: ConnectionRow; +}) { + const { deleteConnections } = useClashConnections(); + + // 详情对话框状态 + const [detailOpen, setDetailOpen] = useState(false); + + // 右键上下文菜单状态 + const [contextMenu, setContextMenu] = useState<{ + mouseX: number; + mouseY: number; + } | null>(null); + + /** + * 处理右键打开上下文菜单 + */ + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu( + contextMenu === null + ? { mouseX: e.clientX - 2, mouseY: e.clientY - 4 } + : null, + ); + }, + [contextMenu], + ); + + /** + * 关闭上下文菜单 + */ + const handleCloseContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + /** + * 关闭当前连接 + */ + const handleCloseConnection = useLockFn(async () => { + handleCloseContextMenu(); + if (detailOpen) { + setDetailOpen(false); + } + await deleteConnections.mutateAsync(data.id); + }); + + return ( + <> + {/* 表格行 - 支持双击和右键 */} + setDetailOpen(true)} + onContextMenu={handleContextMenu} + {...props} + /> + + {/* 右键上下文菜单 */} + + { + setDetailOpen(true); + handleCloseContextMenu(); + }} + > + + {m.connections_view_details()} + + + + {m.connections_close_connection()} + + + + {/* 连接详情对话框 */} + setDetailOpen(false)} + /> + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/connections/index.tsx b/frontend/chimera/src/pages/(main)/main/connections/index.tsx new file mode 100644 index 00000000..e74524be --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/connections/index.tsx @@ -0,0 +1,611 @@ +/** + * 连接页面 + * + * 迁移自 ref: `src/pages/(main)/main/connections/index.tsx` (Step 2) + * + * 职责: + * - 显示所有活跃的网络连接列表 + * - 支持搜索过滤连接(主机、链、规则等) + * - 支持关闭全部连接(右键菜单/工具栏按钮) + * - 使用 @tanstack/react-table + @tanstack/react-virtual 虚拟化表格 + * - 列宽可拖拽调整,状态持久化到 localStorage + * - 支持列排序 + * - 空状态展示 + * + * 迁移步骤(Step 2 - 迁移至 ref 实现): + * - 使用 @tanstack/react-table 替代 Material React Table + * - 使用 @tanstack/react-virtual 实现虚拟滚动 + * - 列宽调整持久化(localStorage) + * - 添加上下行速度计算(基于前后两次快照差值) + * - 添加空状态展示 + * - 工具栏:搜索 + 关闭全部连接按钮 + * - 右键上下文菜单:查看详情 / 关闭连接(在 table-row.tsx 中实现) + * + * 后续迁移计划: + * - 迁移连接详情对话框(table-row.tsx 已实现基础版) + */ + +import { + useClashConnections, + type ClashConnectionItem, +} from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { CloseRounded, InboxOutlined } from '@mui/icons-material'; +import { IconButton, Tooltip } from '@mui/material'; +import { createFileRoute } from '@tanstack/react-router'; +import { + ColumnDef, + ColumnSizingState, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type Updater, +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { useLockFn } from 'ahooks'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { Button } from '@/components/ui/button'; +import { + AppContentScrollArea, + useScrollArea, +} from '@/components/ui/scroll-area'; +import * as m from '@/paraglide/messages'; +import { containsSearchTerm } from '@/utils'; +import parseTraffic from '@/utils/parse-traffic'; +import TableRow from './_modules/table-row'; +import { Route as ConnectionsRoute } from './route'; + +// 启用 dayjs relativeTime 插件 +dayjs.extend(relativeTime); + +/** + * 连接行数据类型 + * 在 ClashConnectionItem 基础上扩展了速度计算字段 + */ +export type ConnectionRow = ClashConnectionItem & { + closed: boolean; + downloadSpeed: number; + uploadSpeed: number; +}; + +/** + * 列宽配置 localStorage 键名 + * 与 ref 保持一致:'connections-column-sizing-v2' + */ +const COLUMN_SIZING_STORAGE_KEY = 'connections-column-sizing-v2'; + +export const Route = createFileRoute('/(main)/main/connections/')({ + component: RouteComponent, +}); + +/** + * 高亮搜索匹配文本的组件 + * 将文本按搜索词分割,匹配部分加亮色背景 + */ +function HighlightText({ + text, + search, +}: { + text: string; + search: string; +}): ReactNode { + if (!search || !text) { + return text; + } + + const lowerText = text.toLowerCase(); + const lowerSearch = search.toLowerCase(); + const idx = lowerText.indexOf(lowerSearch); + + if (idx === -1) { + return text; + } + + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + search.length)} + + {text.slice(idx + search.length)} + + ); +} + +/** + * 连接表格查看器(Viewer) + * + * 负责: + * - 从 WebSocket 拉取连接快照数据 + * - 计算上下行速度 + * - 按搜索词和 proxy 参数过滤 + * - 使用 @tanstack/react-table 渲染表格 + * - 使用 @tanstack/react-virtual 虚拟化大量行 + * - 列宽调整通过 ResizeObserver + localStorage 持久化 + */ +function Viewer({ search }: { search: string }) { + // 从 URL search 参数获取 proxy 过滤条件 + const { proxy } = ConnectionsRoute.useSearch(); + + // 列宽状态(持久化到 localStorage) + const [columnSizing, setColumnSizing] = useLocalStorage( + COLUMN_SIZING_STORAGE_KEY, + {}, + ); + + // WebSocket 连接数据 + const { + query: { data: clashConnections }, + } = useClashConnections(); + + // 获取 ScrollArea 的 viewportRef(与 AnimatedOutletPreset 配合) + const { viewportRef } = useScrollArea(); + + /** + * 处理列宽变更回调 + * 使用 useCallback 避免不必要的重渲染 + */ + const handleColumnSizingChange = useCallback( + (updater: Updater) => { + setColumnSizing((prev) => { + return typeof updater === 'function' ? updater(prev) : updater; + }); + }, + // oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps + [], + ); + + /** + * 计算连接数据(速度、过滤) + * + * 与 ref 一致: + * 1. 取最新的两个快照 + * 2. 按 proxy 过滤(chains 包含指定代理组) + * 3. 计算上下行速度(当前 - 前一次) + * 4. 按搜索词过滤 + */ + const data = useMemo(() => { + const allSnapshots = clashConnections ?? []; + + const latestConnections = allSnapshots.at(-1)?.connections ?? []; + const prevConnections = allSnapshots.at(-2)?.connections ?? []; + + const prevMap = new Map(prevConnections.map((c) => [c.id, c])); + + const all = latestConnections + .filter((conn) => (proxy ? conn.chains?.includes(proxy) : true)) + .map((conn) => { + const prev = prevMap.get(conn.id); + return { + ...conn, + closed: false, + downloadSpeed: prev ? conn.download - prev.download : 0, + uploadSpeed: prev ? conn.upload - prev.upload : 0, + }; + }) + .filter((c) => (search ? containsSearchTerm(c, search) : true)); + + return all; + }, [clashConnections, search, proxy]); + + /** + * 表格列定义 + * 与 ref 一致:Host / Chains / Downloaded / Uploaded / DL Speed / UL Speed / Process / Rule / Time / Source / Destination IP / Type + */ + const columns = useMemo( + () => + [ + { + header: 'Host', + accessorFn: ({ metadata }) => metadata.host || metadata.destinationIP, + size: 320, + cell: (info) => ( + + ), + }, + { + header: 'Chains', + accessorFn: ({ chains }) => [...chains].reverse().join(' / '), + size: 360, + cell: (info) => ( + + ), + }, + { + header: 'Downloaded', + accessorFn: ({ download }) => parseTraffic(download).join(' '), + sortingFn: (rowA, rowB) => + rowA.original.download - rowB.original.download, + size: 120, + cell: (info) => ( + + ), + }, + { + header: 'Uploaded', + accessorFn: ({ upload }) => parseTraffic(upload).join(' '), + sortingFn: (rowA, rowB) => + rowA.original.upload - rowB.original.upload, + size: 120, + cell: (info) => ( + {parseTraffic(info.row.original.upload).join(' ')} + ), + }, + { + header: 'DL Speed', + accessorFn: ({ downloadSpeed }) => + parseTraffic(downloadSpeed).join(' ') + '/s', + sortingFn: (rowA, rowB) => + rowA.original.downloadSpeed - rowB.original.downloadSpeed, + size: 120, + cell: (info) => ( + + {parseTraffic(info.row.original.downloadSpeed).join(' ')}/s + + ), + }, + { + header: 'UL Speed', + accessorFn: ({ uploadSpeed }) => + parseTraffic(uploadSpeed).join(' ') + '/s', + sortingFn: (rowA, rowB) => + rowA.original.uploadSpeed - rowB.original.uploadSpeed, + size: 120, + cell: (info) => ( + + {parseTraffic(info.row.original.uploadSpeed).join(' ')}/s + + ), + }, + { + header: 'Process', + accessorFn: ({ metadata }) => metadata.process, + size: 160, + cell: (info) => ( + + ), + }, + { + header: 'Rule', + accessorFn: ({ rule, rulePayload }) => + rulePayload ? `${rule} (${rulePayload})` : rule, + size: 200, + cell: (info) => ( + + ), + }, + { + header: 'Time', + accessorFn: ({ start }) => dayjs(start).fromNow(), + sortingFn: (rowA, rowB) => + dayjs(rowA.original.start).diff(rowB.original.start), + size: 120, + cell: (info) => ( + + {dayjs(info.row.original.start).fromNow()} + + ), + }, + { + header: 'Source', + accessorFn: ({ metadata: { sourceIP, sourcePort } }) => + `${sourceIP}:${sourcePort}`, + size: 160, + cell: (info) => ( + + ), + }, + { + header: 'Destination IP', + accessorFn: ({ metadata: { destinationIP, destinationPort } }) => + `${destinationIP}:${destinationPort}`, + size: 160, + cell: (info) => ( + + ), + }, + { + header: 'Type', + accessorFn: ({ metadata }) => + `${metadata.type} (${metadata.network})`, + size: 120, + cell: (info) => ( + + ), + }, + ] satisfies Array>, + [search], + ); + + // 初始化 @tanstack/react-table + const table = useReactTable({ + data, + columns, + state: { + columnSizing, + }, + onColumnSizingChange: handleColumnSizingChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableColumnResizing: true, + columnResizeMode: 'onChange', + }); + + const { rows } = table.getRowModel(); + + // 初始化 @tanstack/react-virtual 虚拟滚动 + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => viewportRef.current, + estimateSize: () => 40, + overscan: 10, + measureElement: (element) => element?.getBoundingClientRect().height, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + // 视口宽度监听(用于列宽自适应) + const [viewportWidth, setViewportWidth] = useState(0); + + useEffect(() => { + const viewport = viewportRef.current; + + if (!viewport) { + return; + } + + const updateWidth = () => { + setViewportWidth(viewport.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver(updateWidth); + observer.observe(viewport); + + return () => { + observer.disconnect(); + }; + }, [viewportRef]); + + // 列宽自适应计算 + const visibleColumnCount = table.getVisibleLeafColumns().length; + const tableBaseWidth = table.getTotalSize(); + const extraWidthPerColumn = + visibleColumnCount > 0 && viewportWidth > tableBaseWidth + ? (viewportWidth - tableBaseWidth) / visibleColumnCount + : 0; + const tableRenderWidth = Math.max(tableBaseWidth, viewportWidth); + + // 空状态 + if (rows.length === 0) { + return ( +
+ + +

+ {m.connections_empty_message()} +

+
+ ); + } + + return ( +
+ + {/* 表头 */} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {/* 表体(虚拟行) */} + + {virtualItems.map((virtualRow, index) => { + const row = rows[virtualRow.index]; + + if (!row) { + return null; + } + + const offset = virtualRow.start - index * virtualRow.size; + + return ( + rowVirtualizer.measureElement(node)} + className={cn( + 'transition-colors', + 'hover:bg-primary/5 active:bg-primary/10', + row.original.closed && 'opacity-40', + )} + style={{ + height: `${virtualRow.size}px`, + transform: `translateY(${offset}px)`, + }} + data={row.original} + > + {row.getVisibleCells().map(({ column, id, getContext }) => ( + + ))} + + ); + })} + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {header.column.getIsSorted() === 'asc' && ' ↑'} + {header.column.getIsSorted() === 'desc' && ' ↓'} +
+ )} + + {/* 列宽拖拽手柄 */} + {header.column.getCanResize() && ( +
+ )} +
+ {flexRender(column.columnDef.cell, getContext())} +
+
+ ); +} + +/** + * 连接页面主组件 + * + * 布局: + * - 顶部:可滚动区域中包含 Viewer(虚拟化表格) + * - 底部:工具栏(搜索框 + 关闭全部连接按钮) + * - 使用 useScrollArea 获取 viewportRef 供 Virtualizer 使用 + */ +function RouteComponent() { + const [search, setSearch] = useState(''); + + const { deleteConnections } = useClashConnections(); + + const handleCloseAllConnections = useLockFn(async () => { + await deleteConnections.mutateAsync(undefined); + }); + + return ( +
+ {/* + 可滚动区域(使用 ScrollArea 包裹 Viewer) + ScrollArea 提供 viewportRef,供 @tanstack/react-virtual 使用 + */} + + + + + {/* + 底部工具栏:搜索框 + 关闭全部连接 + 与 ref 的 toolbar 设计一致 + */} +
+ setSearch(e.target.value)} + /> + + + + + + +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/connections/route.tsx b/frontend/chimera/src/pages/(main)/main/connections/route.tsx new file mode 100644 index 00000000..bf3c2baf --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/connections/route.tsx @@ -0,0 +1,181 @@ +/** + * 连接页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/connections/route.tsx` (Step 2) + * + * 职责: + * - 作为 `/main/connections` 路由的父容器 + * - 提供左侧代理过滤侧栏(ProxySelector) + * - 通过 URL search 参数 `?proxy=GroupName` 过滤连接 + * - 使用 Outlet 渲染子路由(连接列表页) + * + * 当前阶段(Step 2 - 迁移至 ref 实现): + * - 使用 Chimera 的 Sidebar 组件替代 ref 的 SliderSidebar + * - 添加 validateSearch 验证 proxy 参数 + * - 从 useClashRules 提取所有代理名称,构建侧栏过滤列表 + * - 侧栏默认收起,可通过点击规则或「所有连接」条目切换 + * - 移动端自动隐藏侧栏 + * + * 状态管理: + * - 侧栏展开/收起状态通过 React state 管理(open/onOpenChange) + * - 使用 URL search params 传递选中的 proxy 过滤条件 + * - 移动端点击条目后自动收起侧栏 + * + * 后续迁移计划: + * - 迁移到 ref 的 SidebarProvider + SliderSidebar 实现(含动画和持久化状态) + */ + +import { useClashRules } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { Tooltip } from '@mui/material'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { useMemo, type ComponentProps } from 'react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import * as m from '@/paraglide/messages'; + +/** + * URL search 参数验证 + * 支持 `?proxy=GroupName` 过滤连接 + */ +export const Route = createFileRoute('/(main)/main/connections')({ + component: RouteComponent, + validateSearch: (search: Record) => ({ + proxy: typeof search.proxy === 'string' ? search.proxy : undefined, + }), +}); + +/** + * 侧栏内容容器 + * 使用 ScrollArea 使内容可滚动 + */ +const ProxySelectorContent = ({ + className, + ...props +}: ComponentProps<'div'>) => { + return
; +}; + +/** + * 代理过滤项 + * 每个规则中的代理(如 Proxy、Reject、Direct 等)作为一个过滤条目 + * 选中时通过 URL search param `proxy=GroupName` 高亮并过滤 + */ +function ProxyFilterItem({ + item, + children, +}: ComponentProps<'div'> & { + item?: string; + children: string; +}) { + const { proxy } = Route.useSearch(); + + return ( + + + + ); +} + +/** + * 代理过滤器组件 + * + * 从 useClashRules 获取所有规则, + * 提取每个规则中的 proxy 字段(去重), + * 构建侧栏过滤列表。 + */ +const ProxySelector = () => { + const { data } = useClashRules(); + + // 从规则中提取所有去重的代理名称 + const allProxy = useMemo(() => { + const proxies = + data?.rules + .map((rule) => rule.proxy) + .filter((proxy): proxy is string => !!proxy) ?? []; + + return [...new Set(proxies)]; + }, [data]); + + return ( + + {/* "所有连接"条目(清除 proxy 过滤条件) */} + {m.connections_all_connections()} + + {/* 每个代理作为一个过滤条目 */} + {allProxy.map((item) => ( + + {item} + + ))} + + ); +}; + +/** + * 连接页面布局组件 + * + * 布局结构(与 ref 一致): + * - 左侧侧栏:代理过滤列表(ProxySelector) + * - 右侧内容区:通过 Outlet 渲染连接列表页 + * + * 注意: + * - 侧栏使用 Chimera 的 Sidebar 组件,移动端自动隐藏 + * - 侧栏内容通过 URL search params 与连接列表页通信 + */ +function RouteComponent() { + return ( + + {/* 左侧侧栏:代理过滤列表 */} + + + + + + + {/* 右侧内容区:连接列表 */} +
+ +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/consts.ts b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/consts.ts new file mode 100644 index 00000000..711eecaf --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/consts.ts @@ -0,0 +1,386 @@ +/** + * Dashboard Widget 常量与类型定义 + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/consts.ts` + * + * 定义 Dashboard DnD 网格系统的核心组件枚举、渲染映射和默认布局。 + * + * Dashboard items 使用 12 列网格布局(CSS Grid 等价), + * 每个 widget 指定 (x, y, w, h) 确定在网格中的位置和尺寸。 + * + * WidgetId 枚举: + * - TrafficDown/Up: 上下行流量趋势图(Sparkline) + * - Connections: 连接数趋势图(Sparkline) + * - Memory: 内存使用趋势图(Sparkline) + * - ProxyShortcuts: 系统代理 / TUN 模式快捷开关 + * - CoreShortcuts: 当前运行核心状态卡片 + */ + +import type { ReactNode } from 'react'; +import type { DndGridItemType } from '@/components/ui/dnd-grid'; +import { CoreShortcutsWidget, ProxyShortcutsWidget } from './widget-shortcut'; +import { + ConnectionsWidget, + MemoryWidget, + TrafficDownWidget, + TrafficUpWidget, +} from './widget-sparkline'; + +/** 所有 Dashboard Widget 的唯一标识符枚举 */ +export enum WidgetId { + TrafficDown = 'traffic-down', + TrafficUp = 'traffic-up', + Connections = 'connections', + Memory = 'memory', + ProxyShortcuts = 'proxy-shortcuts', + CoreShortcuts = 'core-shortcuts', +} + +/** Dashboard 网格项类型 — 在 DndGridItemType 基础上添加 type 字段 */ +export type DashboardItem = DndGridItemType & { type: WidgetId }; + +/** Widget 组件接收的 props */ +export type WidgetComponentProps = { + id: string; + onCloseClick?: (id: string) => void; +}; + +/** + * Widget 类型 → 组件渲染映射表 + * 每个 WidgetId 映射到对应的 React 组件 + */ +export const RENDER_MAP: Record< + WidgetId, + (props: WidgetComponentProps) => ReactNode +> = { + [WidgetId.TrafficDown]: TrafficDownWidget, + [WidgetId.TrafficUp]: TrafficUpWidget, + [WidgetId.Connections]: ConnectionsWidget, + [WidgetId.Memory]: MemoryWidget, + [WidgetId.ProxyShortcuts]: ProxyShortcutsWidget, + [WidgetId.CoreShortcuts]: CoreShortcutsWidget, +}; + +/** + * 默认布局(12 列网格) + * + * 布局说明: + * - 第 0~1 行:4 个 Sparkline 卡片并排(TrafficDown, TrafficUp, Memory, Connections) + * - 第 2 行起:ProxyShortcuts(3 列宽,3 行高)+ CoreShortcuts(4 列宽,2 行高) + */ +export const DEFAULT_ITEMS: DashboardItem[] = [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 3, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 6, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 9, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.ProxyShortcuts, + type: WidgetId.ProxyShortcuts, + x: 0, + y: 2, + w: 3, + h: 3, + }, + { + id: WidgetId.CoreShortcuts, + type: WidgetId.CoreShortcuts, + x: 3, + y: 2, + w: 4, + h: 2, + }, +]; + +/** 各 Widget 的最小尺寸限制 */ +export const WIDGET_MIN_SIZE_MAP: Record< + WidgetId, + { minW: number; minH: number } +> = { + [WidgetId.TrafficDown]: { minW: 2, minH: 2 }, + [WidgetId.TrafficUp]: { minW: 2, minH: 2 }, + [WidgetId.Connections]: { minW: 2, minH: 2 }, + [WidgetId.Memory]: { minW: 2, minH: 2 }, + [WidgetId.ProxyShortcuts]: { minW: 3, minH: 2 }, + [WidgetId.CoreShortcuts]: { minW: 4, minH: 2 }, +}; + +/** 持久化存储的布局数据结构 */ +export type LayoutStorage = Record; + +/** + * 针对不同网格尺寸的预设布局 + * + * 键名为 `{cols}x{rows}` 格式,例如 '12x6' 表示 12 列 6 行的网格布局。 + * 当窗口尺寸变化时,WidgetRender 会查找最匹配的预设布局。 + * + * 预设布局包括:4x5(最小)、8x6、12x6(默认)、16x6(宽屏)、20x6(超宽屏) + */ +export const DEFAULT_LAYOUTS: LayoutStorage = { + // 4 列 5 行 — 极窄布局(手机/小窗口) + '4x5': [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 2, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 0, + y: 2, + w: 2, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 2, + y: 2, + w: 2, + h: 2, + }, + ], + // 8 列 6 行 — 窄屏布局 + '8x6': [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 2, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 4, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 6, + y: 0, + w: 2, + h: 2, + }, + { + id: WidgetId.ProxyShortcuts, + type: WidgetId.ProxyShortcuts, + x: 0, + y: 2, + w: 3, + h: 2, + }, + { + id: WidgetId.CoreShortcuts, + type: WidgetId.CoreShortcuts, + x: 3, + y: 2, + w: 5, + h: 2, + }, + ], + // 12 列 6 行 — 默认布局 + '12x6': [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 3, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 6, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 9, + y: 0, + w: 3, + h: 2, + }, + { + id: WidgetId.ProxyShortcuts, + type: WidgetId.ProxyShortcuts, + x: 0, + y: 2, + w: 3, + h: 2, + }, + { + id: WidgetId.CoreShortcuts, + type: WidgetId.CoreShortcuts, + x: 3, + y: 2, + w: 5, + h: 2, + }, + ], + // 16 列 6 行 — 宽屏布局 + '16x6': [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 4, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 4, + y: 0, + w: 4, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 8, + y: 0, + w: 4, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 12, + y: 0, + w: 4, + h: 2, + }, + { + id: WidgetId.ProxyShortcuts, + type: WidgetId.ProxyShortcuts, + x: 0, + y: 2, + w: 4, + h: 3, + }, + { + id: WidgetId.CoreShortcuts, + type: WidgetId.CoreShortcuts, + x: 4, + y: 2, + w: 5, + h: 2, + }, + ], + // 20 列 6 行 — 超宽屏布局 + '20x6': [ + { + id: WidgetId.TrafficDown, + type: WidgetId.TrafficDown, + x: 0, + y: 0, + w: 5, + h: 2, + }, + { + id: WidgetId.TrafficUp, + type: WidgetId.TrafficUp, + x: 5, + y: 0, + w: 5, + h: 2, + }, + { + id: WidgetId.Memory, + type: WidgetId.Memory, + x: 10, + y: 0, + w: 5, + h: 2, + }, + { + id: WidgetId.Connections, + type: WidgetId.Connections, + x: 15, + y: 0, + w: 5, + h: 2, + }, + { + id: WidgetId.ProxyShortcuts, + type: WidgetId.ProxyShortcuts, + x: 0, + y: 2, + w: 5, + h: 3, + }, + { + id: WidgetId.CoreShortcuts, + type: WidgetId.CoreShortcuts, + x: 5, + y: 2, + w: 5, + h: 2, + }, + ], +}; diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/edit-action.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/edit-action.tsx new file mode 100644 index 00000000..211043ac --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/edit-action.tsx @@ -0,0 +1,78 @@ +/** + * Dashboard 编辑操作浮动栏 + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/edit-action.tsx` + * + * 在编辑模式下显示在 Dashboard 底部的浮动操作栏: + * - "添加 Widget" 按钮:打开 WidgetSheet + * - "保存" 按钮:退出编辑模式 + * + * 使用 framer-motion 的 AnimatePresence 实现进出场动画。 + */ + +import { cn } from '@chimera/ui'; +import AddRounded from '~icons/material-symbols/add-rounded'; +import DoneRounded from '~icons/material-symbols/done-rounded'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Button } from '@/components/ui/button'; +import { useDashboardContext } from './provider'; + +export default function EditAction() { + const { isEditing, setIsEditing, setOpenSheet } = useDashboardContext(); + + return ( + + {isEditing && ( + + {/* 添加 Widget 按钮 - 打开 WidgetSheet */} + + + {/* 保存按钮 - 退出编辑模式 */} + + + )} + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/layout-adapt.ts b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/layout-adapt.ts new file mode 100644 index 00000000..dc188b27 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/layout-adapt.ts @@ -0,0 +1,216 @@ +/** + * Dashboard 布局自适应工具函数 + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/layout-adapt.ts` + * + * 职责: + * - 当窗口/网格尺寸变化时,从预设布局中找到最匹配的布局 + * - 如果没有任何预设布局完全匹配,提供布局自适应算法 + * - 自适应算法:按优先级 clamp → 找空闲位置 → 缩小 → 丢弃 + * + * 布局适应逻辑(adaptLayout): + * 1. 按阅读顺序(自上而下,自左而右)处理每个项 + * 2. 先尝试 clamp 到边界内,若无重叠则保留 + * 3. 若重叠,在当前尺寸下扫描空闲位置 + * 4. 若仍不可放置,逐步缩小尺寸(向 minW/minH 方向) + * 5. 若最小尺寸也无法放置,丢弃该项 + */ + +import type { + DndGridItemType, + GridItemConstraints, + GridSize, +} from '@/components/ui/dnd-grid'; +import { isOverlap } from '@/components/ui/dnd-grid/utils'; + +/** + * 将 GridSize 转换为存储键名 `{cols}x{rows}` + */ +export function sizeKey(size: GridSize): string { + return `${size.cols}x${size.rows}`; +} + +/** + * 在预设布局存储中找到最佳匹配项 + * + * 扫描所有尺寸 ≤ 当前尺寸的预设布局,返回面积最大的那个。 + * 例如当前是 14x6,返回 12x6 的布局(如果存在)。 + * + * @param storage - 预设布局存储 + * @param size - 当前网格尺寸 + * @returns 匹配的布局项,或 null + */ +export function findBestLayout>( + storage: Record, + size: GridSize, +): T[] | null { + let best: { area: number; items: T[] } | null = null; + + for (const [key, items] of Object.entries(storage)) { + const match = key.match(/^(\d+)x(\d+)$/); + if (!match) continue; + + const cols = parseInt(match[1], 10); + const rows = parseInt(match[2], 10); + + if (cols <= size.cols && rows <= size.rows) { + const area = cols * rows; + + if (!best || area > best.area) { + best = { area, items }; + } + } + } + + return best?.items ?? null; +} + +/** + * 当没有任何预设布局能完全匹配时,找到尺寸最接近的布局 + * 使用曼哈顿距离 (|cols - cols| + |rows - rows|) 衡量接近程度 + * + * @param storage - 预设布局存储 + * @param size - 当前网格尺寸 + * @returns 最接近的布局项,或 null(存储为空时) + */ +export function findClosestStoredLayout>( + storage: Record, + size: GridSize, +): T[] | null { + let best: { dist: number; items: T[] } | null = null; + + for (const [key, items] of Object.entries(storage)) { + const match = key.match(/^(\d+)x(\d+)$/); + if (!match) continue; + + const cols = parseInt(match[1], 10); + const rows = parseInt(match[2], 10); + const dist = Math.abs(cols - size.cols) + Math.abs(rows - size.rows); + + if (!best || dist < best.dist) { + best = { dist, items }; + } + } + + return best?.items ?? null; +} + +/** + * 检查候选位置是否与已放置列表中的项重叠(排除自身) + */ +function hasOverlapWith>( + placed: T[], + candidate: T, +): boolean { + return placed.some((p) => p.id !== candidate.id && isOverlap(p, candidate)); +} + +/** + * 自上而下、自左而右扫描,找到第一个可容纳 (w × h) 的空闲位置 + * + * @returns 放置后的项,或 null(无空闲位置) + */ +function tryPlace>( + item: T, + w: number, + h: number, + placed: T[], + cols: number, + rows: number, +): T | null { + for (let y = 0; y + h <= rows; y++) { + for (let x = 0; x + w <= cols; x++) { + const candidate = { ...item, x, y, w, h } as T; + + if (!hasOverlapWith(placed, candidate)) { + return candidate; + } + } + } + return null; +} + +/** + * 自适应布局算法 + * + * 将给定的 items 适应到新的网格尺寸中。处理顺序按阅读顺序(top→bottom, left→right), + * 先处理的项有更高的优先级。 + * + * 适应策略(逐项应用): + * 1. clamp:将 (x,y) 限制在边界内,保持原始 (w,h),若无重叠则保留 + * 2. 扫描:在当前 (w,h) 下找空闲位置 + * 3. 缩小:向 (minW, minH) 方向逐步缩小 (w,h) 并重试 + * 4. 丢弃:如果最小尺寸也无法放置,丢弃该项 + * + * @param items - 原始布局项列表 + * @param size - 目标网格尺寸 + * @param constraints - 各项的尺寸约束 + * @returns 自适应后的布局项列表 + */ +export function adaptLayout>( + items: T[], + size: GridSize, + constraints: Record, +): T[] { + const { cols, rows } = size; + const result: T[] = []; + + // 按阅读顺序排序 + const sorted = [...items].sort((a, b) => + a.y !== b.y ? a.y - b.y : a.x - b.x, + ); + + for (const item of sorted) { + const c = constraints[item.id] ?? {}; + const minW = c.minW ?? 1; + const minH = c.minH ?? 1; + + // 即使最小尺寸也放不下 → 丢弃 + if (minW > cols || minH > rows) continue; + + // 将尺寸限制在 [minW..cols] 和 [minH..rows] 范围内 + const w = Math.max(minW, Math.min(item.w, cols)); + const h = Math.max(minH, Math.min(item.h, rows)); + // 将位置限制在边界内 + const x = Math.max(0, Math.min(item.x, cols - w)); + const y = Math.max(0, Math.min(item.y, rows - h)); + + const clamped = { ...item, x, y, w, h } as T; + + // 步骤 1:尝试 clamp 后的位置(无重叠) + if (!hasOverlapWith(result, clamped)) { + result.push(clamped); + continue; + } + + // 步骤 2:在当前 (w, h) 下扫描空闲位置 + const placed = tryPlace(item, w, h, result, cols, rows); + if (placed) { + result.push(placed); + continue; + } + + // 步骤 3:逐渐缩小 (w, h) 并向 (minW, minH) 靠近并重试 + const findShrinkPlacement = (): T | null => { + for (let tw = w; tw >= minW; tw--) { + for (let th = h; th >= minH; th--) { + if (tw === w && th === h) continue; // 已在步骤 2 尝试过 + + const p = tryPlace(item, tw, th, result, cols, rows); + + if (p) { + return p; + } + } + } + + return null; + }; + + const found = findShrinkPlacement(); + if (found) result.push(found); + // else: 丢弃该项 + } + + return result; +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/provider.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/provider.tsx new file mode 100644 index 00000000..fda1b281 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/provider.tsx @@ -0,0 +1,58 @@ +/** + * Dashboard 上下文 Provider + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/provider.tsx` + * + * 提供 Dashboard 页面的全局状态: + * - openSheet: WidgetSheet(添加 widget 的抽屉)是否打开 + * - isEditing: 是否处于编辑模式(显示调整手柄和删除按钮) + * + * 在 route.tsx 中通过 DashboardProvider 注入整个 Dashboard 页面树。 + */ + +import { createContext, use, useState, type PropsWithChildren } from 'react'; + +const DashboardContext = createContext<{ + openSheet: boolean; + setOpenSheet: (open: boolean) => void; + isEditing: boolean; + setIsEditing: (editing: boolean) => void; +} | null>(null); + +/** + * 获取 Dashboard 上下文的 Hook + * 必须在 DashboardProvider 内部使用,否则抛出错误 + */ +export const useDashboardContext = () => { + const context = use(DashboardContext); + + if (!context) { + throw new Error( + 'useDashboardContext must be used within a DashboardProvider', + ); + } + + return context; +}; + +/** + * Dashboard 上下文 Provider + * 注入 openSheet/isEditing 状态到子组件树 + */ +export function DashboardProvider({ children }: PropsWithChildren) { + const [openSheet, setOpenSheet] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + return ( + + {children} + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-item.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-item.tsx new file mode 100644 index 00000000..48252d4a --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-item.tsx @@ -0,0 +1,84 @@ +/** + * Dashboard Widget 容器项 + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/widget-item.tsx` + * + * 包装 DndGridItem,添加编辑模式下的关闭按钮。 + * 关闭按钮通过 AnimatePresence 实现显隐动画。 + * + * 在编辑模式(!disabled && !sourceOnly)时显示关闭按钮, + * 点击后通过 onCloseClick 回调通知父组件删除该 widget。 + */ + +import { cn } from '@chimera/ui'; +import CloseRounded from '~icons/material-symbols/close-rounded'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Button } from '@/components/ui/button'; +import { DndGridItem, type DndGridItemProps } from '@/components/ui/dnd-grid'; +import { useDndGridContext } from '@/components/ui/dnd-grid/context'; +import type { WidgetComponentProps } from './consts'; + +export type WidgetItemProps = DndGridItemProps & WidgetComponentProps; + +/** + * WidgetItem — 可拖拽、可关闭的 widget 容器 + * + * 用法: + * ```tsx + * + *
widget content
+ *
+ * ``` + */ +export default function WidgetItem({ + children, + className, + onCloseClick, + ...props +}: WidgetItemProps) { + const { disabled, sourceOnly } = useDndGridContext(); + + return ( + + {children} + + {/* 编辑模式下显示关闭按钮 */} + + {!disabled && !sourceOnly && ( + + )} + + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sheet.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sheet.tsx new file mode 100644 index 00000000..8b2449f1 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sheet.tsx @@ -0,0 +1,138 @@ +/** + * Dashboard Widget 选择面板(底部抽屉) + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/widget-sheet.tsx` + * + * 使用 vaul 的 Drawer 组件实现的底部抽屉面板, + * 显示所有可用的 widget 预览列表。 + * 用户可以从这里拖拽 widget 添加到 Dashboard 主网格。 + * + * 核心逻辑: + * - sheetItems 使用 WIDGET_MIN_SIZE_MAP 计算每个 widget 的最小尺寸 + * - 使用 DndGrid(sourceOnly 模式)渲染可拖拽的 widget 预览 + * - 当用户拖拽 widget 到主网格时,通过 onSourceDrop 回调通知 + * - 拖拽开始时自动关闭抽屉(通过 onSourceDragStart) + */ + +import { cn } from '@chimera/ui'; +import CloseRounded from '~icons/material-symbols/close-rounded'; +import { useMemo, useState } from 'react'; +import { Drawer } from 'vaul'; +import { Button } from '@/components/ui/button'; +import { DndGrid, type GridSize } from '@/components/ui/dnd-grid'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { RENDER_MAP, WIDGET_MIN_SIZE_MAP, WidgetId } from './consts'; +import { useDashboardContext } from './provider'; + +export function WidgetSheet({ + onSourceDrop, + onSourceDragStart, +}: { + onSourceDrop: (id: WidgetId) => void; + onSourceDragStart: () => void; +}) { + const { openSheet, setOpenSheet } = useDashboardContext(); + + const [gridSize, setGridSize] = useState(); + + /** + * 计算在 WidgetSheet 中展示所有可用 widget 所需的布局 + * + * 按 WIDGET_MIN_SIZE_MAP 定义的最小尺寸排列,自动换行。 + * 如果 gridSize 尚未计算完成,返回空数组(等待首次 ResizeObserver 回调)。 + */ + const sheetItems = useMemo(() => { + if (!gridSize) { + return []; + } + + const ids = Object.keys(RENDER_MAP) as WidgetId[]; + const result = []; + let rowX = 0; + let rowY = 0; + let rowH = 0; + + for (const id of ids) { + const { minW: w, minH: h } = WIDGET_MIN_SIZE_MAP[id]; + + // 换行 + if (rowX + w > gridSize.cols) { + rowY += rowH; + rowX = 0; + rowH = 0; + } + + result.push({ id, x: rowX, y: rowY, w, h }); + rowX += w; + rowH = Math.max(rowH, h); + } + + return result; + }, [gridSize]); + + return ( + + + {/* 半透明背景遮罩 */} + + + + {/* 头部标题 + 关闭按钮 */} +
+ + 添加小组件 + + + + + +
+ + {/* Widget 列表滚动区域 */} + div]:block!', + '[&_[data-slot=scroll-area-viewport]>div]:h-full', + )} + > +
+ onSourceDrop(id as WidgetId)} + onSourceDragStart={onSourceDragStart} + onSizeChange={(size) => setGridSize(size)} + > + {(item) => { + const WidgetComponent = RENDER_MAP[item.id as WidgetId]; + + return ( + {}} /> + ); + }} + +
+
+
+
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-shortcut.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-shortcut.tsx new file mode 100644 index 00000000..f916fda5 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-shortcut.tsx @@ -0,0 +1,355 @@ +/** + * Dashboard 快捷操作 Widget + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/widget-shortcut.tsx` + * + * 两个 Widget 组件: + * 1. ProxyShortcutsWidget — 系统代理 / TUN 模式快捷开关 + * 2. CoreShortcutsWidget — 当前运行核心状态卡片 + * + * 适配说明: + * - 使用 Chimera 的 PaperSwitchButton 替代 ref 的 SystemProxyButton/TunModeButton + * - 核心状态显示使用 Chimera 的 getCoreStatus 接口 + * - 使用 div 基础卡片样式替代 ref 的 Card/CardHeader/CardContent 组件 + * - 使用 @chimera/interface 的 hooks 替代 @nyanpasu/interface + */ + +import { + getCoreStatus, + useClashConfig, + useClashCores, + useSetting, + useSystemProxy, + useSystemService, + type CoreState, +} from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { Link } from '@tanstack/react-router'; +import NetworkPing from '~icons/material-symbols/network-ping'; +import SettingsEthernet from '~icons/material-symbols/settings-ethernet-rounded'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { PaperSwitchButton } from '@/components/setting/modules/system-proxy'; +import { Button } from '@/components/ui/button'; +import TextMarquee from '@/components/ui/text-marquee'; +import { m } from '@/paraglide/messages'; +import type { WidgetComponentProps } from './consts'; +import WidgetItem from './widget-item'; + +/** 代理状态枚举 */ +enum ProxyStatus { + SYSTEM = 'system', + TUN = 'tun', + OCCUPIED = 'occupied', + DISABLED = 'disabled', +} + +/** + * 代理状态标题行 + * 显示当前系统代理/TUN 模式状态和相应颜色的标签 + */ +const ProxyTitleRow = () => { + const { value: enableSystemProxy } = useSetting('enable_system_proxy'); + const { value: enableTunMode } = useSetting('enable_tun_mode'); + const { data: systemProxyStatus } = useSystemProxy(); + const { + query: { data: clashConfigs }, + } = useClashConfig(); + + const status = useMemo(() => { + if (enableTunMode) { + return ProxyStatus.TUN; + } + + if (enableSystemProxy) { + if (systemProxyStatus?.enable) { + const port = Number(systemProxyStatus.server.split(':')[1]); + + if (port === clashConfigs?.['mixed-port']) { + return ProxyStatus.SYSTEM; + } + + return ProxyStatus.OCCUPIED; + } + } + + return ProxyStatus.DISABLED; + }, [enableSystemProxy, enableTunMode, systemProxyStatus, clashConfigs]); + + const messages = { + [ProxyStatus.SYSTEM]: '系统代理', + [ProxyStatus.TUN]: 'TUN 模式', + [ProxyStatus.OCCUPIED]: '占用', + [ProxyStatus.DISABLED]: '已禁用', + }; + + return ( +
+ + {m.dashboard_widget_proxy_status()} + + + +
+ ); +}; + +/** + * ProxyShortcutsWidget — 系统代理 / TUN 模式快捷开关 + * + * 显示: + * - 当前代理状态标题 + * - 系统代理开关按钮 + * - TUN 模式开关按钮 + * + * 使用 PaperSwitchButton 组件实现开关,与 Chimera 设置页面保持一致。 + */ +export function ProxyShortcutsWidget({ + id, + onCloseClick, +}: WidgetComponentProps) { + const systemProxy = useSetting('enable_system_proxy'); + const tunMode = useSetting('enable_tun_mode'); + + return ( + +
+ + +
+ {/* 系统代理按钮 */} + systemProxy.upsert(!systemProxy.value)} + > +
+ + + {m.settings_system_proxy_system_proxy_label()} + +
+
+ + {/* TUN 模式按钮 */} + tunMode.upsert(!tunMode.value)} + > +
+ + + {m.settings_system_proxy_tun_mode_label()} + +
+
+
+
+
+ ); +} + +/** + * 核心状态徽章 + * 显示核心运行状态的文本描述 + */ +const CoreStatusBadge = () => { + const { + query: { data: serviceStatus }, + } = useSystemService(); + + const coreStatusSWR = useSWR('/coreStatus', getCoreStatus, { + refreshInterval: 2000, + revalidateOnFocus: false, + }); + + const message = useMemo(() => { + const status = coreStatusSWR.data?.[0]; + const coreState = status as CoreState | undefined; + const isRunning = coreState === 'Running'; + + if (isRunning) { + if (serviceStatus?.server?.core_infos?.state === 'Running') { + return '核心以服务模式运行'; + } + return '核心以子进程运行'; + } + + let stoppedMessage = '核心已停止'; + let serviceMessage = ''; + + const stoppedInfo = + coreState && typeof coreState === 'object' && 'Stopped' in coreState + ? coreState.Stopped + : null; + + if (serviceStatus?.status === 'running') { + serviceMessage = '服务运行中'; + + if (stoppedInfo) { + stoppedMessage = `核心被服务停止: ${stoppedInfo}`; + } else { + stoppedMessage = '核心被服务停止(未知原因)'; + } + } else if (serviceStatus?.status === 'stopped') { + serviceMessage = '服务已停止'; + } else { + serviceMessage = '服务未安装'; + } + + if (stoppedInfo) { + stoppedMessage = `核心已停止: ${stoppedInfo}`; + } + + return `${stoppedMessage} ${serviceMessage}`; + }, [serviceStatus, coreStatusSWR.data]); + + return ( +
+ + {message} + +
+ ); +}; + +/** + * 当前核心卡片 + * 显示当前选中的核心名称、版本和运行状态 + */ +const CurrentCoreCard = () => { + const { query: clashCores } = useClashCores(); + const { value: currentCoreKey } = useSetting('clash_core'); + const coreStatusSWR = useSWR('/coreStatus', getCoreStatus, { + refreshInterval: 2000, + revalidateOnFocus: false, + }); + + const status = coreStatusSWR.data?.[0] as CoreState | undefined; + const isRunning = status === 'Running'; + const currentCore = currentCoreKey ? clashCores.data?.[currentCoreKey] : null; + + return ( + + ); +}; + +/** + * CoreShortcutsWidget — 当前运行核心状态卡片 + * + * 显示: + * - 核心状态徽章(运行中/已停止/服务状态) + * - 当前核心的详细信息卡片(名称、版本、运行状态指示器) + * - 点击进入设置-核心页面 + */ +export function CoreShortcutsWidget({ + id, + onCloseClick, +}: WidgetComponentProps) { + return ( + +
+
+ + {m.dashboard_widget_core_status()} + + + +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sparkline.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sparkline.tsx new file mode 100644 index 00000000..c64d983e --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/_modules/widget-sparkline.tsx @@ -0,0 +1,311 @@ +/** + * Dashboard Sparkline 趋势图 Widget + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/_modules/widget-sparkline.tsx` + * + * 四个 Widget 组件,使用 Sparkline 趋势图展示实时数据: + * 1. TrafficDownWidget — 下行流量趋势(附带总流量) + * 2. TrafficUpWidget — 上行流量趋势(附带总流量) + * 3. ConnectionsWidget — 连接数趋势 + * 4. MemoryWidget — 内存使用趋势 + * + * 使用 @chimera/interface 的 hooks 获取实时数据: + * - useClashTraffic:上下行流量数据(每秒更新) + * - useClashConnections:连接数和总流量汇总 + * - useClashMemory:内存使用数据(mihomo 核心时可用) + * + * 适配说明: + * - 使用 div 基础卡片样式替代 ref 的 Card/CardHeader/CardContent 组件 + * - Sparkline 组件已迁移至 @/components/ui/sparkline + * - 接口包路径改为 @chimera/interface + */ + +import { + MAX_CONNECTIONS_HISTORY, + MAX_MEMORY_HISTORY, + MAX_TRAFFIC_HISTORY, + useClashConnections, + useClashMemory, + useClashTraffic, + type ClashConnection, +} from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import ArrowDownwardRounded from '~icons/material-symbols/arrow-downward-rounded'; +import ArrowUpwardRounded from '~icons/material-symbols/arrow-upward-rounded'; +import MemoryOutlineRounded from '~icons/material-symbols/memory-outline-rounded'; +import SettingsEthernetRounded from '~icons/material-symbols/settings-ethernet-rounded'; +import { filesize } from 'filesize'; +import type { ComponentProps, ComponentType } from 'react'; +import { Sparkline } from '@/components/ui/sparkline'; +import TextMarquee from '@/components/ui/text-marquee'; +import * as m from '@/paraglide/messages'; +import type { WidgetComponentProps } from './consts'; +import WidgetItem from './widget-item'; + +/** + * 填充数据数组 + * 当数据不足 max 长度时,在数组前补零以达到 max 长度 + */ +const padData = (data: (number | undefined)[] = [], max: number) => + Array(Math.max(0, max - data.length)) + .fill(0) + .concat(data.slice(-max)); + +/** + * SparklineCard — 趋势图卡片容器 + * + * 包装 WidgetItem 和 Sparkline 组件,提供: + * - 背景趋势图(Sparkline 占满整个卡片背景) + * - 前景内容(标题、数值、底部信息)在 z-index 上层 + */ +function SparklineCard({ + id, + minH = 2, + minW = 2, + maxW, + maxH, + data, + className, + children, + onCloseClick, + ...props +}: ComponentProps<'div'> & { + data: number[]; +} & { + id: string; + minW?: number; + minH?: number; + maxW?: number; + maxH?: number; + onCloseClick?: (id: string) => void; +}) { + return ( + +
+ {/* 背景 Sparkline 趋势图 */} + + + {/* 前景内容层 */} +
+ {children} +
+
+
+ ); +} + +/** + * SparklineCardTitle — 标题行(图标 + 文本) + */ +function SparklineCardTitle({ + icon: Icon, + className, + children, + ...props +}: ComponentProps<'div'> & { + icon: ComponentType<{ className?: string }>; +}) { + return ( +
+ + {children} +
+ ); +} + +/** + * SparklineCardContent — 主要数值显示 + */ +function SparklineCardContent({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * SparklineCardBottom — 底部次要信息 + */ +function SparklineCardBottom({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * TrafficDownWidget — 下行流量趋势 + * + * 显示: + * - Sparkline 趋势图(下行速率历史) + * - 当前下行速率(filesize 格式化) + * - 总下载量(从连接汇总数据中获取) + */ +export function TrafficDownWidget({ id, onCloseClick }: WidgetComponentProps) { + const { data: clashTraffic } = useClashTraffic(); + const { query: connectionsQuery } = useClashConnections(); + const clashConnections = connectionsQuery.data; + const total = clashConnections?.at(-1)?.downloadTotal; + + return ( + item.down), + MAX_TRAFFIC_HISTORY, + )} + onCloseClick={onCloseClick} + > + + {m.dashboard_widget_traffic_download()} + + + + {filesize(clashTraffic?.at(-1)?.down ?? 0)}/s + + + + {total !== undefined && + m.dashboard_widget_traffic_total({ + value: filesize(total), + })} + + + ); +} + +/** + * TrafficUpWidget — 上行流量趋势 + * + * 显示: + * - Sparkline 趋势图(上行速率历史) + * - 当前上行速率(filesize 格式化) + * - 总上传量(从连接汇总数据中获取) + */ +export function TrafficUpWidget({ id, onCloseClick }: WidgetComponentProps) { + const { data: clashTraffic } = useClashTraffic(); + const { query: connectionsQuery } = useClashConnections(); + const clashConnections = connectionsQuery.data; + const total = clashConnections?.at(-1)?.uploadTotal; + + return ( + item.up), + MAX_TRAFFIC_HISTORY, + )} + onCloseClick={onCloseClick} + > + + {m.dashboard_widget_traffic_upload()} + + + + {filesize(clashTraffic?.at(-1)?.up ?? 0)}/s + + + + {total !== undefined && + m.dashboard_widget_traffic_total({ + value: filesize(total), + })} + + + ); +} + +/** + * ConnectionsWidget — 连接数趋势 + * + * 显示: + * - Sparkline 趋势图(连接数历史) + * - 当前连接数 + */ +export function ConnectionsWidget({ id, onCloseClick }: WidgetComponentProps) { + const { query: connectionsQuery } = useClashConnections(); + const clashConnections = connectionsQuery.data; + + return ( + item.connections?.length ?? 0, + ), + MAX_CONNECTIONS_HISTORY, + )} + onCloseClick={onCloseClick} + > + + {m.dashboard_widget_connections()} + + + + {clashConnections?.at(-1)?.connections?.length ?? 0} + + + + + ); +} + +/** + * MemoryWidget — 内存使用趋势 + * + * 显示: + * - Sparkline 趋势图(内存使用历史) + * - 当前内存使用量(filesize 格式化) + * - 仅在 mihomo 核心支持时显示有效数据 + */ +export function MemoryWidget({ id, onCloseClick }: WidgetComponentProps) { + const { data: clashMemory } = useClashMemory(); + + return ( + item.inuse), + MAX_MEMORY_HISTORY, + )} + onCloseClick={onCloseClick} + > + + {m.dashboard_widget_memory()} + + + + {filesize(clashMemory?.at(-1)?.inuse ?? 0)} + + + + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/index.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/index.tsx new file mode 100644 index 00000000..60dc8e5a --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/index.tsx @@ -0,0 +1,369 @@ +/** + * Dashboard 主页(Widget 网格系统) + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/index.tsx` + * + * 职责: + * - 展示可拖拽、可调整大小的 Dashboard DnD widget 网格 + * - 支持编辑模式:添加 widget、调整大小、删除 widget、拖拽重排 + * - 布局持久化:通过 useKvStorage 保存用户自定义布局 + * - 响应式自适应:当窗口尺寸变化时,自动切换最匹配的预设布局 + * - 提供 WidgetSheet(底部抽屉)让用户添加新的 widget + * - 支持从 WidgetSheet 拖拽 widget 到主网格 + * + * 核心组件: + * - WidgetRender:管理 DndGrid 布局状态,处理尺寸变化、布局持久化、widget 添加 + * - DashboardDragOverlay:拖拽时的 DragOverlay 预览 + * - EditAction:编辑模式浮动操作栏 + * - WidgetSheet:底部抽屉,显示所有可用 widget + * + * 布局自适应流程: + * 1. 窗口尺寸变化 → DndGrid onSizeChange + * 2. findBestLayout 查找最匹配的预设布局 + * 3. 如果无匹配 → findClosestStoredLayout + adaptLayout 自适应 + * 4. 用户拖拽/调整后 → handleLayoutChange 持久化到 KvStorage + * + * 适配说明: + * - 使用 @dnd-kit/core 的 DragOverlay 替代 ref 中的嵌套(已统一到 DndGridRoot) + * - 使用 useKvStorage 持久化布局(与 Chimera 的 KV 存储兼容) + * - 跳过 ref 的 context-menu(Chimera 无此基础设施),改用 EditAction 编辑按钮 + */ + +import { useKvStorage } from '@chimera/interface'; +import { DragOverlay } from '@dnd-kit/core'; +import { createFileRoute } from '@tanstack/react-router'; +import EditRounded from '~icons/material-symbols/edit-rounded'; +import { useCallback, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DndGrid, + DndGridRoot, + useDndGridRoot, + type DndGridItemType, + type GridItemConstraints, + type GridSize, +} from '@/components/ui/dnd-grid'; +import { hasOverlap } from '@/components/ui/dnd-grid/utils'; +import { + DashboardItem, + DEFAULT_ITEMS, + DEFAULT_LAYOUTS, + LayoutStorage, + RENDER_MAP, + WIDGET_MIN_SIZE_MAP, + WidgetId, +} from './_modules/consts'; +import EditAction from './_modules/edit-action'; +import { + adaptLayout, + findBestLayout, + findClosestStoredLayout, + sizeKey, +} from './_modules/layout-adapt'; +import { useDashboardContext } from './_modules/provider'; +import { WidgetSheet } from './_modules/widget-sheet'; + +/** + * 注册 TanStack Router 文件路由 + * 路径: `/main/dashboard/`(index 子路由) + * 匹配 ref: `createFileRoute('/(main)/main/dashboard/')` + */ +export const Route = createFileRoute('/(main)/main/dashboard/')({ + component: RouteComponent, +}); + +/** + * 标准化网格项:确保每个项都有 type 属性 + * 从存储中恢复的项可能缺少 type 字段(旧格式兼容) + */ +function normalizeItems(items: DndGridItemType[]): DashboardItem[] { + return items.map((item) => ({ + ...item, + type: (item as DashboardItem).type ?? (item.id as WidgetId), + })); +} + +/** + * DashboardDragOverlay — 拖拽时的浮动预览 + * + * 在 DndGridRoot 上下文中读取 activeDrag 信息, + * 渲染被拖拽 widget 的预览(包裹在 WidgetComponent 中)。 + * + * 使用 DndGridProvider 子上下文确保 overlay 内的 WidgetComponent + * 能正确获取 isOverlay=true 状态(跳过定位逻辑)。 + */ +function DashboardDragOverlay({ + displayItems, +}: { + displayItems: DashboardItem[]; +}) { + const root = useDndGridRoot(); + const activeDrag = root?.activeDrag ?? null; + + return ( + + {activeDrag && + (() => { + const widgetType = + displayItems.find((i) => i.id === activeDrag.itemId)?.type ?? + (activeDrag.itemId as WidgetId); + const WidgetComponent = RENDER_MAP[widgetType]; + + if (!WidgetComponent) { + return null; + } + + return ( +
+ {/* + 为 overlay 内的 WidgetComponent 提供一个最小 DndGridProvider, + 使其能够正常渲染(isOverlay=true 跳过定位,displayItems 和回调为空) + */} +
+ +
+
+ ); + })()} +
+ ); +} + +/** + * WidgetRender — 核心 widget 网格渲染器 + * + * 管理 DndGrid 的布局状态,包括: + * - 网格尺寸变化时的布局自适应(findBestLayout → adaptLayout) + * - 用户拖拽/调整大小后的布局持久化(useKvStorage) + * - 从 WidgetSheet 添加新 widget(addWidgetFromSheet) + * + * 使用 ref 跟踪最新状态,避免闭包过期问题。 + */ +const WidgetRender = () => { + const { isEditing, setOpenSheet } = useDashboardContext(); + + // 持久化布局存储(从 KvStorage 读取/写入) + const [layoutStorage, setLayoutStorage] = useKvStorage( + 'dashboard-widgets', + DEFAULT_LAYOUTS, + ); + + // 当前显示的 widget 项列表 + const [displayItems, setDisplayItems] = + useState(DEFAULT_ITEMS); + + // 使用 ref 避免闭包过期 + const layoutStorageRef = useRef(layoutStorage); + layoutStorageRef.current = layoutStorage; + + const displayItemsRef = useRef(displayItems); + displayItemsRef.current = displayItems; + + const gridSizeRef = useRef({ cols: 1, rows: 1 }); + + /** + * 网格尺寸变化处理 + * + * 逻辑: + * 1. 先在预设布局存储中查找完全匹配的布局 + * 2. 如果有,直接使用 + * 3. 如果没有,查找最接近的预设布局 + adaptLayout 自适应 + */ + const handleSizeChange = useCallback( + ( + newSize: GridSize, + constraintsMap: Record, + ) => { + gridSizeRef.current = newSize; + + // 尝试找到完全匹配的预设布局 + const bestLayout = findBestLayout(layoutStorageRef.current, newSize); + + if (bestLayout) { + const normalized = normalizeItems(bestLayout); + displayItemsRef.current = normalized; + setDisplayItems(normalized); + return; + } + + // 无完全匹配 → 找最接近的 + 自适应 + const base = + findClosestStoredLayout(layoutStorageRef.current, newSize) ?? + DEFAULT_ITEMS; + + const nextItems = normalizeItems( + adaptLayout(base, newSize, constraintsMap), + ); + displayItemsRef.current = nextItems; + setDisplayItems(nextItems); + }, + [], + ); + + /** + * 布局变化处理(拖拽/调整大小后) + * 更新当前显示状态并持久化到 KvStorage + */ + const handleLayoutChange = useCallback( + (newItems: DashboardItem[]) => { + const key = sizeKey(gridSizeRef.current); + displayItemsRef.current = newItems; + setDisplayItems(newItems); + + // 更新并持久化布局 + layoutStorageRef.current = { + ...layoutStorageRef.current, + [key]: newItems, + }; + setLayoutStorage(layoutStorageRef.current); + }, + [setLayoutStorage], + ); + + /** + * 从 WidgetSheet 添加新 widget + * + * 逻辑: + * 1. 使用 WIDGET_MIN_SIZE_MAP 获取 widget 的最小尺寸 + * 2. 在网格中从左上到右下扫描空闲位置 + * 3. 如果找到空闲位置,放置在那里 + * 4. 如果无空闲位置,放置在最后一行的下一个可用行 + */ + const addWidgetFromSheet = useCallback( + (widgetId: WidgetId) => { + const { minW, minH } = WIDGET_MIN_SIZE_MAP[widgetId]; + const { cols, rows } = gridSizeRef.current; + const current = displayItemsRef.current; + const instanceId = crypto.randomUUID(); + + // 扫描空闲位置 + const findPlacement = (): DashboardItem => { + for (let y = 0; y <= rows; y++) { + for (let x = 0; x <= cols - minW; x++) { + const candidate: DashboardItem = { + id: instanceId, + type: widgetId, + x, + y, + w: minW, + h: minH, + }; + + if (!hasOverlap(current, instanceId, candidate)) { + return candidate; + } + } + } + + // 无空闲位置 → 追加到最底部 + const maxY = current.reduce((m, i) => Math.max(m, i.y + i.h), 0); + return { + id: instanceId, + type: widgetId, + x: 0, + y: maxY, + w: minW, + h: minH, + }; + }; + + handleLayoutChange([...current, findPlacement()]); + }, + [handleLayoutChange], + ); + + return ( + +
+ {/* DnD Widget 网格 */} + + handleLayoutChange(normalizeItems(newItems)) + } + minCellSize={64} + onSizeChange={handleSizeChange} + gap={16} + disabled={!isEditing} + > + {(item) => { + const WidgetComponent = RENDER_MAP[(item as DashboardItem).type]; + + return ( + + handleLayoutChange( + displayItemsRef.current.filter((i) => i.id !== item.id), + ) + } + /> + ); + }} + +
+ + {/* 拖拽浮动预览 */} + + + {/* Widget 添加面板 */} + addWidgetFromSheet(id)} + onSourceDragStart={() => setOpenSheet(false)} + /> +
+ ); +}; + +/** + * Dashboard 页面主组件 + * + * 布局结构(匹配 ref): + * - 编辑按钮(右上角) + * - WidgetRender(DndGrid 网格系统) + * - EditAction(编辑模式浮动栏) + * + * 使用 DndGridRoot 管理多网格拖拽事件路由, + * EditAction 在编辑模式时显示浮动操作栏。 + */ +function RouteComponent() { + const { isEditing, setIsEditing, setOpenSheet } = useDashboardContext(); + + return ( +
+ {/* 编辑按钮 — 点击进入编辑模式 */} + {!isEditing && ( +
+ +
+ )} + + {/* Widget 网格 */} + + + {/* 编辑模式浮动操作栏 */} + +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/dashboard/route.tsx b/frontend/chimera/src/pages/(main)/main/dashboard/route.tsx new file mode 100644 index 00000000..9fef6cba --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/dashboard/route.tsx @@ -0,0 +1,47 @@ +/** + * Dashboard 路由父布局 + * + * 迁移自 ref: `src/pages/(main)/main/dashboard/route.tsx` + * + * 职责: + * - 作为 `/main/dashboard` 路由的容器 + * - 提供 DashboardProvider(编辑/添加 widget 状态管理) + * - 匹配 ref 的 DashboardProvider 包裹模式 + * + * 路由结构: + * - `/main/dashboard`(此路由):仅 Outlet,包裹 DashboardProvider + * - `/main/dashboard/`(index 子路由):实际页面内容在 index.tsx 中 + * + * DashboardProvider 提供: + * - openSheet / setOpenSheet:控制 WidgetSheet(添加 widget 的抽屉)的开关 + * - isEditing / setIsEditing:控制编辑模式的开关 + */ + +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { DashboardProvider } from './_modules/provider'; + +/** + * 注册 TanStack Router 文件路由 + * 路径: `/main/dashboard` + * 匹配 ref: `createFileRoute('/(main)/main/dashboard')` + */ +export const Route = createFileRoute('/(main)/main/dashboard')({ + component: RouteComponent, +}); + +/** + * Dashboard 路由组件 + * + * 使用 DashboardProvider 包裹所有子路由, + * 使 index.tsx 中的 WidgetRender、EditAction、WidgetSheet + * 能够通过 useDashboardContext 访问编辑/添加状态。 + * + * 匹配 ref: route.tsx 中的 DashboardProvider 包裹模式 + */ +function RouteComponent() { + return ( + + + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/index.tsx b/frontend/chimera/src/pages/(main)/main/index.tsx index ec95903f..8033883f 100644 --- a/frontend/chimera/src/pages/(main)/main/index.tsx +++ b/frontend/chimera/src/pages/(main)/main/index.tsx @@ -1,3 +1,16 @@ +/** + * (main) 布局的默认 landing page + * + * 迁移自 ref: `src/pages/(main)/main/index.tsx` + * + * 将用户重定向到 Dashboard 主页。 + * + * 变迁说明: + * - 旧版 (legacy) 重定向到 memorizedRoute(记录的上次访问路径) + * - 新版 (main) 始终以 Dashboard 为入口,与 ref 行为一致 + * - 未来可改为记忆用户上次访问的页面 + */ + import { createFileRoute, Navigate } from '@tanstack/react-router'; export const Route = createFileRoute('/(main)/main/')({ @@ -5,5 +18,6 @@ export const Route = createFileRoute('/(main)/main/')({ }); function RouteComponent() { - return ; + // 匹配 ref: 默认跳转到 Dashboard 页面 + return ; } diff --git a/frontend/chimera/src/pages/(main)/main/logs/_modules/consts.ts b/frontend/chimera/src/pages/(main)/main/logs/_modules/consts.ts new file mode 100644 index 00000000..3097df78 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/logs/_modules/consts.ts @@ -0,0 +1,17 @@ +/** + * 日志页面常量 + * + * 迁移自 ref: `src/pages/(main)/main/logs/_modules/consts.ts` + * + * 定义日志级别的枚举,包含所有 Clash 核心支持的日志类型: + * - debug: 调试信息(最详细) + * - info: 普通信息 + * - warning: 警告信息 + * - error: 错误信息 + */ +export enum LogLevel { + Debug = 'debug', + Info = 'info', + Warning = 'warning', + Error = 'error', +} diff --git a/frontend/chimera/src/pages/(main)/main/logs/_modules/log-level-badge.tsx b/frontend/chimera/src/pages/(main)/main/logs/_modules/log-level-badge.tsx new file mode 100644 index 00000000..1deed1f6 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/logs/_modules/log-level-badge.tsx @@ -0,0 +1,42 @@ +/** + * 日志级别徽章组件 + * + * 迁移自 ref: `src/pages/(main)/main/logs/_modules/log-level-badge.tsx` + * + * 职责: + * - 根据日志级别显示不同颜色的徽章 + * - 支持搜索高亮(通过 searchText 属性) + * - info 蓝色 / warning 黄色 / error 红色 / debug 默认 + */ + +import { cn } from '@chimera/ui'; +import { type ComponentProps } from 'react'; + +export default function LogLevelBadge({ + className, + children, + ...props +}: ComponentProps<'div'> & { children: string }) { + const childrenLower = children?.toLowerCase(); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/logs/index.tsx b/frontend/chimera/src/pages/(main)/main/logs/index.tsx new file mode 100644 index 00000000..67d3fe49 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/logs/index.tsx @@ -0,0 +1,280 @@ +/** + * 日志页面 + * + * 迁移自 ref: `src/pages/(main)/main/logs/index.tsx` + * + * 职责: + * - 显示 Clash 核心运行日志 + * - 支持日志级别过滤(通过 URL search params + 侧栏) + * - 支持搜索过滤日志内容 + * - 使用 @tanstack/react-virtual 虚拟化大量日志条目 + * - 自动滚动到最新日志(当用户位于底部时) + * - 右键清空日志 + * - 空状态展示 + * + * 当前阶段(Logs Step 2 - 迁移至 ref 实现): + * - 使用 @tanstack/react-virtual 替代 virtua 实现虚拟滚动 + * - 使用 URL search params 驱动日志级别过滤(与 route.tsx 配合) + * - 添加搜索过滤功能 + * - 添加空状态展示 + * - 添加自动滚动到底部行为 + * - 使用 LogLevelBadge 显示日志级别 + * + * 后续迁移计划: + * - 迁移到 ref 的 HighlightText 组件(搜索高亮) + * - 添加右键菜单(RegisterContextMenu) + * - 优化自动滚动逻辑 + */ + +import { useClashLogs } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { DeleteSweepOutlined, InboxOutlined } from '@mui/icons-material'; +import { IconButton, Tooltip } from '@mui/material'; +import { createFileRoute } from '@tanstack/react-router'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useLockFn } from 'ahooks'; +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import * as m from '@/paraglide/messages'; +import LogLevelBadge from './_modules/log-level-badge'; +import { Route as LogsRoute } from './route'; + +export const Route = createFileRoute('/(main)/main/logs/')({ + component: RouteComponent, +}); + +/** + * 日志内容查看器(Viewer) + * + * 负责: + * - 根据 URL search params 的 level 过滤日志 + * - 根据搜索词过滤日志 + * - 使用 @tanstack/react-virtual 虚拟化渲染 + * - 当用户位于底部时自动滚动到最新日志 + */ +function Viewer({ + search, + scrollRef, +}: { + search: string; + scrollRef: RefObject; +}) { + // 从父路由获取日志级别过滤条件 + const { level } = LogsRoute.useSearch(); + + // 从 WebSocket 获取实时日志 + const { + query: { data: logs }, + } = useClashLogs(); + + /** + * 过滤日志: + * 1. 按日志级别过滤(来自 URL search params) + * 2. 按搜索词过滤 + */ + const filteredLogs = useMemo(() => { + if (!logs) { + return []; + } + + const levelFiltered = !level + ? logs + : logs.filter((log) => log.type.toLowerCase() === level); + + if (!search) { + return levelFiltered; + } + + const lowerSearch = search.toLowerCase(); + return levelFiltered.filter( + (log) => + log.payload?.toLowerCase().includes(lowerSearch) || + log.type?.toLowerCase().includes(lowerSearch), + ); + }, [logs, level, search]); + + // 初始化虚拟滚动 + const rowVirtualizer = useVirtualizer({ + count: filteredLogs.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 60, + overscan: 5, + measureElement: (element) => element?.getBoundingClientRect().height, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + /** + * 自动滚动到底部: + * 当新日志到达且用户当前位于底部时, + * 平滑滚动到最新日志位置 + */ + useEffect(() => { + if (filteredLogs.length === 0) { + return; + } + + const scrollEl = scrollRef.current; + if (!scrollEl) { + return; + } + + // 判断用户是否位于底部(允许 40px 误差) + const isAtBottom = + scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 40; + + if (isAtBottom) { + rowVirtualizer.scrollToIndex(filteredLogs.length - 1, { + align: 'end', + behavior: 'smooth', + }); + } + }, [filteredLogs, rowVirtualizer]); + + // 空状态 + if (filteredLogs.length === 0) { + return ( +
+ + +

+ {m.logs_empty_message()} +

+
+ ); + } + + return ( +
+ {virtualItems.map((virtualItem) => { + const log = filteredLogs[virtualItem.index]; + + if (!log) { + return null; + } + + return ( +
+
+ {/* 日志时间 */} + + {log.time || ''} + + + {/* 日志级别徽章 */} + {log.type} +
+ + {/* 日志内容 */} +
+ {log.payload || ''} +
+
+ ); + })} +
+ ); +} + +/** + * 日志页面主组件 + * + * 布局: + * - 可滚动区域:Viewer(虚拟化日志列表) + * - 底部工具栏:搜索框 + 清空日志按钮 + * + * 与 ref 不同之处: + * - 使用 Chimera 的 MUI 图标和 Tooltip + * - 右键清空日志功能可通过工具栏按钮替代 + */ +function RouteComponent() { + const [search, setSearch] = useState(''); + const scrollRef = useRef(null); + + const { + query: { data: logs }, + clean, + } = useClashLogs(); + + const handleClearLogs = useLockFn(async () => { + await clean.mutateAsync(); + }); + + return ( +
+ {/* + 日志内容区域 - 可滚动 + 使用 ref 作为 scrollRef 供 @tanstack/react-virtual 使用 + */} +
+ +
+ + {/* + 底部工具栏:搜索框 + 清空日志按钮 + */} +
+ setSearch(e.target.value)} + /> + + + + + + + + +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/logs/route.tsx b/frontend/chimera/src/pages/(main)/main/logs/route.tsx new file mode 100644 index 00000000..7a54797b --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/logs/route.tsx @@ -0,0 +1,145 @@ +/** + * 日志页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/logs/route.tsx` + * + * 职责: + * - 作为 `/main/logs` 路由的父容器 + * - 提供左侧日志级别过滤侧栏 + * - 通过 URL search 参数 `?level=error` 过滤日志 + * - 使用 Outlet 渲染子路由(日志列表页) + * + * 当前阶段(Logs Step 2 - 迁移至 ref 实现): + * - 添加 validateSearch 验证 level 参数(枚举:debug/info/warning/error) + * - 使用 Chimera 的 Sidebar 组件替代 ref 的 SliderSidebar + * - 侧栏显示所有日志级别按钮(All / Debug / Info / Warning / Error) + * - 每个级别按钮使用 emoji 图标(与 ref 一致) + * - 选中的级别在 URL search params 中反映 + */ + +import { Tooltip } from '@mui/material'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import * as m from '@/paraglide/messages'; +import { LogLevel } from './_modules/consts'; + +/** + * 日志级别对应的 emoji 图标 + * 与 ref 一致:debug 🐛 / info ℹ️ / warning ⚠️ / error ❌ / all 📋 + */ +const LogLevelIcon: Record string> = { + [LogLevel.Debug]: () => '🐛', + [LogLevel.Info]: () => 'ℹ️', + [LogLevel.Warning]: () => '⚠️', + [LogLevel.Error]: () => '❌', + all: () => '📋', +}; + +/** + * URL search 参数验证 + * 支持 `?level=debug|info|warning|error` 过滤日志级别 + * 使用手动验证(而非 zod,因为项目未引入 zod) + */ +export const Route = createFileRoute('/(main)/main/logs')({ + component: RouteComponent, + validateSearch: (search: Record) => { + const level = search.level; + return { + level: + typeof level === 'string' && + Object.values(LogLevel).includes(level as LogLevel) + ? (level as LogLevel) + : undefined, + }; + }, +}); + +/** + * 日志级别侧栏按钮 + * 点击后通过 URL search params 设置 level 过滤条件 + */ +function LogLevelButton({ + level, + children, +}: { + /** 日志级别值,undefined 表示「全部」 */ + level?: LogLevel; + children: string; +}) { + const { level: currentLevel } = Route.useSearch(); + const Icon = level ? LogLevelIcon[level] : LogLevelIcon['all']; + + const isActive = level === currentLevel; + + return ( + + + + ); +} + +/** + * 日志页面布局组件 + * + * 布局结构(与 ref 一致): + * - 左侧侧栏:日志级别过滤列表(All / Debug / Info / Warning / Error) + * - 右侧内容区:通过 Outlet 渲染日志列表页 + */ +function RouteComponent() { + const logLevelEntries = Object.values(LogLevel); + + return ( + + {/* 左侧侧栏:日志级别过滤 */} + + +
+ {/* "全部"条目(清除 level 过滤条件) */} + {'All'} + + {/* 每个日志级别作为一个过滤条目 */} + {logLevelEntries.map((level) => ( + + {level} + + ))} +
+
+
+ + {/* 右侧内容区:日志列表 */} +
+ +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/profiles/$type/index.tsx b/frontend/chimera/src/pages/(main)/main/profiles/$type/index.tsx new file mode 100644 index 00000000..24417cd0 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/$type/index.tsx @@ -0,0 +1,116 @@ +/** + * 按类型过滤的配置文件页面(子路由) + * + * 迁移自 ref: `src/pages/(main)/main/profiles/$type/index.tsx` + * + * 职责: + * - 作为 `/main/profiles/$type` 路由,按 ProfileType 显示过滤后的配置列表 + * - 显示配置类型对应的名称和图标 + * - 过滤方式与 ProfilesNavigate 侧栏中的 PROFILE_TYPE_CONDITIONS 一致 + * + * 当前阶段(Profiles Step 2 — 子路由迁移): + * - 创建 $type 子路由,使侧栏导航链接可正常跳转 + * - 使用现有的 Chimera 配置列表组件(filterProfiles + ProfileItem) + * - 仅支持 Profile 类型(显示全部 remote/local 配置) + * - JavaScript / Lua / Merge 类型显示占位状态 + * + * 后续计划: + * - 添加 JavaScript/Lua 脚本配置的专用视图 + * - 添加 Merge 配置的专用视图 + * - 添加配置搜索/排序功能 + */ + +import { useProfile } from '@chimera/interface'; +import { Public } from '@mui/icons-material'; +import { Grid } from '@mui/material'; +import { createFileRoute } from '@tanstack/react-router'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useMemo } from 'react'; +import ProfileItem from '@/components/profiles/profile-item'; +import { filterProfiles } from '@/components/profiles/utils'; +import { PROFILE_TYPE_NAMES, ProfileType } from '../_modules/consts'; + +export const Route = createFileRoute('/(main)/main/profiles/$type/')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { type } = Route.useParams(); + + const { query } = useProfile(); + + // 过滤后的配置文件列表 + const profiles = useMemo(() => { + return filterProfiles(query.data?.items); + }, [query.data?.items]); + + const profileItems = profiles?.clash ?? []; + + const profileType = type as ProfileType; + const typeName = PROFILE_TYPE_NAMES[profileType] ?? type; + + return ( +
+ {/* 类型标题 */} +
+

{typeName}

+ + + ({profileItems.length} profiles) + +
+ + {/* 配置网格 */} +
+ {profileItems.length > 0 ? ( + + {profileItems.map((item) => ( + + + {}} + selected={query.data?.current?.includes(item.uid)} + chainsSelected={false} + /> + + + ))} + + ) : ( +
+
+ +
{typeName}
+
+ No {typeName.toLowerCase()} profiles +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/profiles/_modules/consts.ts b/frontend/chimera/src/pages/(main)/main/profiles/_modules/consts.ts new file mode 100644 index 00000000..f3a00a6f --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/_modules/consts.ts @@ -0,0 +1,51 @@ +/** + * Profiles 模块共享常量 + * + * 迁移自 ref: `src/pages/(main)/main/profiles/_modules/consts.ts` + * + * 职责: + * - 定义 ProfileType 枚举(Profile / JavaScript / Lua / Merge) + * - 定义每种 Profile 类型对应的过滤条件 + * - 提供类型名称映射 + * + * 被 ProfilesNavigate 和 $type 子路由共享 + */ + +import * as m from '@/paraglide/messages'; + +/** + * Profile 类型枚举 + * 用于按配置类型过滤和导航 + */ +export enum ProfileType { + Profile = 'profile', + JavaScript = 'javascript', + Lua = 'lua', + Merge = 'merge', +} + +/** + * Profile 类型到显示名称的映射 + */ +export const PROFILE_TYPE_NAMES = { + [ProfileType.Profile]: m.profile_profile_label(), + [ProfileType.JavaScript]: m.profile_javascript_label(), + [ProfileType.Lua]: m.profile_lua_label(), + [ProfileType.Merge]: m.profile_merge_label(), +} satisfies Record; + +/** + * Profile 类型到过滤条件的映射 + * 每种类型对应一组匹配条件,用于筛选配置列表 + */ +export const PROFILE_TYPE_CONDITIONS = { + [ProfileType.Profile]: [ + { type: 'remote' as const }, + { type: 'local' as const }, + ], + [ProfileType.JavaScript]: [ + { type: 'script' as const, script_type: 'javascript' as const }, + ], + [ProfileType.Lua]: [{ type: 'script' as const, script_type: 'lua' as const }], + [ProfileType.Merge]: [{ type: 'merge' as const }], +} as const; diff --git a/frontend/chimera/src/pages/(main)/main/profiles/_modules/profiles-navigate.tsx b/frontend/chimera/src/pages/(main)/main/profiles/_modules/profiles-navigate.tsx new file mode 100644 index 00000000..2fd72668 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/_modules/profiles-navigate.tsx @@ -0,0 +1,197 @@ +/** + * Profiles 侧栏导航 + * + * 迁移自 ref: `src/pages/(main)/main/profiles/_modules/profiles-navigate.tsx` + * + * 职责: + * - 在 Profiles 页面的左侧侧边栏显示 + * - 按配置类型(Profile / JavaScript / Lua / Merge / Inspect)提供导航链接 + * - 显示每种类型的配置数量 + * + * 当前阶段(Profiles Step 2 — 侧栏导航迁移): + * - 匹配 ref 的 ProfilesNavigate 设计 + * - 使用 material-symbols 图标替代 ref 的混合图标集(mdi/nonicons/streamline-plump) + * - 支持使用 useProfile 获取配置数量统计 + * + * 后续计划: + * - 添加 ProfileType 子路由支持($type/) + * - 添加 Inspect 子路由支持 + */ + +import { useProfile } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { Link, useMatchRoute } from '@tanstack/react-router'; +import CallMergeRounded from '~icons/material-symbols/call-merge-rounded'; +import CodeRounded from '~icons/material-symbols/code-rounded'; +import DescriptionOutlineRounded from '~icons/material-symbols/description-outline-rounded'; +import JavascriptRounded from '~icons/material-symbols/javascript-rounded'; +import SearchRounded from '~icons/material-symbols/search-rounded'; +import { mapValues } from 'lodash-es'; +import { useMemo, type ComponentProps, type ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import * as m from '@/paraglide/messages'; +/** + * 使用共享常量(ProfileType, PROFILE_TYPE_CONDITIONS) + * 迁移自 ref: `src/pages/(main)/main/profiles/_modules/consts.ts` + */ + +import { PROFILE_TYPE_CONDITIONS, ProfileType } from './consts'; + +/** + * 导航按钮组件 + * - 使用 useMatchRoute 检测当前路由是否匹配 + * - 匹配时自动高亮(data-active=true) + * - 通过 asChild 将 Button 包装为 Link + */ +const LinkButton = ({ + href, + exact = false, + children, +}: { + href: string; + exact?: boolean; + children: ReactNode; +}) => { + const matchRoute = useMatchRoute(); + + const isActive = !!matchRoute({ + to: href, + fuzzy: !exact, + }); + + return ( + + ); +}; + +/** + * 路由配置 + * 每个配置类型对应一个导航目标(href)和图标 + */ +const ROUTES = { + [ProfileType.Profile]: { + label: m.profile_profile_label(), + href: '/main/profiles', + icon: () => ( +
+ +
+ ), + }, + [ProfileType.JavaScript]: { + label: m.profile_javascript_label(), + href: '/main/profiles/javascript', + icon: () => ( +
+ +
+ ), + }, + [ProfileType.Lua]: { + label: m.profile_lua_label(), + href: '/main/profiles/lua', + icon: () => ( +
+ +
+ ), + }, + [ProfileType.Merge]: { + label: m.profile_merge_label(), + href: '/main/profiles/merge', + icon: () => ( +
+ +
+ ), + }, +} satisfies Record< + ProfileType, + { + label: string; + href: string; + icon: () => ReactNode; + } +>; + +/** + * Profiles 侧栏导航组件 + * + * 在 SidebarContent 中渲染,显示: + * - 每种配置类型的链接(带图标和数量统计) + * - 分隔线 + * - Inspect 链接 + */ +export default function ProfilesNavigate({ + className, + ...props +}: Omit, 'children'>) { + const { + query: { data: profiles }, + } = useProfile(); + + // 统计每种类型的配置数量 + const counts = useMemo>( + () => + mapValues( + PROFILE_TYPE_CONDITIONS, + (conditions) => + (profiles?.items ?? []).filter((profile) => + conditions.some( + (condition) => + profile.type === condition.type && + (!('script_type' in condition) || + ('script_type' in profile && + profile.script_type === condition.script_type)), + ), + ).length, + ), + [profiles?.items], + ); + + return ( +
+ {Object.entries(ROUTES).map(([profileType, route]) => ( + +
{route.icon()}
+ +
+

{route.label}

+ +

+ {m.profile_profile_label_count({ + count: counts[profileType as ProfileType] ?? 0, + })} +

+
+
+ ))} + + + + +
+ +
+ + Profile Inspect +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/profiles/index.tsx b/frontend/chimera/src/pages/(main)/main/profiles/index.tsx new file mode 100644 index 00000000..f0cb01d9 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/index.tsx @@ -0,0 +1,388 @@ +/** + * 配置文件页面 + * + * 迁移自 ref: `src/pages/(main)/main/profiles/index.tsx` + * + * 职责: + * - 显示所有订阅/本地配置文件列表 + * - 支持 Profile 链式编辑(Global Proxy Chains / Per-profile Chains) + * - 快速导入订阅链接 + * - 更新全部配置、新增配置(浮动操作按钮) + * - 运行时配置差异查看 + * + * 当前阶段(Step 5): + * - 使用现有的 Chimera 配置文件组件 + * - 适配 (main) 布局(使用 AppContentScrollArea 替代 SidePage) + * - 保留链编辑侧栏(ProfileSide)覆盖层 + * - 保留浮动操作按钮(FAB) + * - 移除 SidePage 包装,改用扁平布局 + * + * 后续迁移计划: + * - 迁移到 ref 的配置文件组件实现 + * - 添加拖拽排序支持 + * - 添加配置搜索过滤 + */ + +import { RemoteProfileOptionsBuilder, useProfile } from '@chimera/interface'; +import { + ErrorOutlined, + Public, + TextSnippetOutlined, + Update, +} from '@mui/icons-material'; +import { + Badge, + Button, + CircularProgress, + Fab, + Grid, + IconButton, +} from '@mui/material'; +import { createFileRoute } from '@tanstack/react-router'; +import { useLockFn } from 'ahooks'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useAtom } from 'jotai'; +import { useMemo, useState, useTransition } from 'react'; +import { useWindowSize } from 'react-use'; +import ContentDisplay from '@/components/base/content-display'; +import { + atomChainsSelected, + atomGlobalChainCurrent, +} from '@/components/profiles/modules/store'; +import NewProfileButton from '@/components/profiles/new-profile-button'; +import { + AddProfileContext, + type AddProfileContextValue, +} from '@/components/profiles/profile-dialog'; +import ProfileItem from '@/components/profiles/profile-item'; +import ProfileSide from '@/components/profiles/profile-side'; +import { GlobalUpdatePendingContext } from '@/components/profiles/provider'; +import { QuickImport } from '@/components/profiles/quick-import'; +import RuntimeConfigDiffDialog from '@/components/profiles/runtime-config-diff-dialog'; +import { ClashProfile, filterProfiles } from '@/components/profiles/utils'; +import { AppContentScrollArea } from '@/components/ui/scroll-area'; +import * as m from '@/paraglide/messages'; +import { formatError } from '@/utils'; +import { message } from '@/utils/notification'; + +/** + * 注册 TanStack Router 文件路由 + * 路径: `/main/profiles/`(index 子路由) + * 匹配 ref: `createFileRoute('/(main)/main/profiles/')` + * URL 参数已在父路由(route.tsx)中验证 + */ +export const Route = createFileRoute('/(main)/main/profiles/')({ + component: ProfilePage, +}); + +type RemoteProfileItem = Extract; + +/** + * 配置文件列表页主组件 + * + * 在 (main) 布局下提供: + * - 配置文件网格(支持 local/remote 类型) + * - 链式编辑面板(Global Proxy Chains / Per-profile Chains) + * - 快速导入区域 + * - 浮动操作按钮(更新全部 / 新增配置) + * - 运行时配置查看器 + */ +function ProfilePage() { + const { query, update } = useProfile(); + const search = Route.useSearch(); + + // 过滤后的配置文件列表(仅 clash 类型) + const profiles = useMemo(() => { + return filterProfiles(query.data?.items); + }, [query.data?.items]); + const profileItems = profiles?.clash ?? []; + + // 从 URL search 参数中提取订阅链接,用于预填 AddProfileDialog + const addProfileCtxValue = useMemo( + () => + search.subscribeUrl + ? ({ + name: search.subscribeName ?? null, + desc: search.subscribeDesc ?? null, + url: search.subscribeUrl, + } satisfies AddProfileContextValue) + : null, + [search], + ); + + // 链式编辑状态管理 + const [globalChain, setGlobalChain] = useAtom(atomGlobalChainCurrent); + const [chainsSelected, setChainsSelected] = useAtom(atomChainsSelected); + + const handleChainsClick = (profile: ClashProfile) => { + setGlobalChain(false); + + if (chainsSelected === profile.uid) { + setChainsSelected(undefined); + } else { + setChainsSelected(profile.uid); + } + }; + + const handleGlobalChainClick = () => { + setChainsSelected(undefined); + setGlobalChain(!globalChain); + }; + + // 侧栏面板是否处于显示状态 + const hasSide = globalChain || chainsSelected; + + const handleSideClose = () => { + setChainsSelected(undefined); + setGlobalChain(false); + }; + + // 运行时配置查看器 + const [runtimeConfigViewerOpen, setRuntimeConfigViewerOpen] = useState(false); + const { width } = useWindowSize(); + + // 全局更新配置状态 + const [globalUpdatePending, startGlobalUpdate] = useTransition(); + + /** + * 更新所有远程配置 + * 遍历 profiles 中所有 remote 类型配置,逐个发起更新 + */ + const handleGlobalProfileUpdate = useLockFn(async () => { + startGlobalUpdate(async () => { + const remoteProfiles = + (profiles?.clash?.filter( + (item) => item.type === 'remote', + ) as RemoteProfileItem[]) || []; + + const updates: Array> = []; + + for (const profile of remoteProfiles) { + const option = { + ...profile.option, + update_interval: 0, + user_agent: profile.option?.user_agent ?? null, + } satisfies RemoteProfileOptionsBuilder; + + const result = await update.mutateAsync({ + uid: profile.uid, + option, + }); + updates.push(Promise.resolve(result)); + } + + try { + await Promise.all(updates); + } catch (error) { + message(`failed to update profiles: \n${formatError(error)}`, { + kind: 'error', + title: 'Error', + }); + } + }); + }); + + /** + * 根据加载/错误/空状态渲染不同内容 + */ + const renderProfilesContent = () => { + // 加载中 + if (query.isLoading) { + return ( + +
+
+ +
{'Loading profiles'}
+
+
+
+ ); + } + + // 加载出错 + if (query.isError) { + return ( + +
+
+ +
+ {'Failed to load profiles'} +
+
+ {formatError(query.error)} +
+ +
+
+
+ ); + } + + // 无配置文件 + if (!profileItems.length) { + return ( + +
+
+ +
{'No Profiles'}
+
+ {'Import a subscription URL to get started'} +
+
+
+
+ ); + } + + // 配置文件网格 + return ( + + {profileItems.map((item) => ( + + + + + + ))} + + ); + }; + + return ( +
+ {/* + profiles 内容区域(可滚动) + 不使用 SidePage 包装,(main) 布局已提供导航栏 + 链编辑侧栏(ProfileSide)作为覆盖层在内容之上显示 + */} +
+ {/* 主内容区 */} +
+ {/* + 顶栏:运行时配置查看器 + 全局链按钮 + 匹配 legacy layout 中 SidePage header 的行为 + */} +
+ setRuntimeConfigViewerOpen(false)} + /> + + { + setRuntimeConfigViewerOpen(true); + }} + > + + + + + + +
+ + {/* 配置文件列表 */} + + + +
+ {/* 快速导入区域 */} + + + {/* 配置文件网格 */} + {renderProfilesContent()} +
+
+
+
+
+ + {/* + 链编辑侧栏覆盖层(ProfileSide) + 当选中某个配置的链或启用全局链时显示 + 覆盖在主内容区右侧 + */} + {hasSide && ( +
+ +
+ )} +
+ + {/* + 浮动操作按钮(FAB):更新全部 + 新增配置 + 固定在右下角,不随滚动移动 + */} +
+ + {globalUpdatePending ? : } + + + + + +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/profiles/inspect/route.tsx b/frontend/chimera/src/pages/(main)/main/profiles/inspect/route.tsx new file mode 100644 index 00000000..5edacf2d --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/inspect/route.tsx @@ -0,0 +1,35 @@ +/** + * Profiles Inspect 页面(占位) + * + * 迁移自 ref: `src/pages/(main)/main/profiles/inspect/route.tsx` + * + * 职责: + * - 作为 `/main/profiles/inspect` 路由 + * - 用于内部检查和分析配置文件的结构、规则、代理等信息 + * + * 当前阶段:占位实现 + * - 匹配 ref 的占位内容 + * - 后续可添加配置内省功能(YAML/JS/Lua 解析与可视化) + */ + +import DescriptionOutlined from '@mui/icons-material/DescriptionOutlined'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/(main)/main/profiles/inspect')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+ + +

+ Profile Inspect — coming soon +

+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/profiles/route.tsx b/frontend/chimera/src/pages/(main)/main/profiles/route.tsx new file mode 100644 index 00000000..62c82879 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/profiles/route.tsx @@ -0,0 +1,118 @@ +/** + * 配置文件页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/profiles/route.tsx` + * + * 职责: + * - 提供双栏布局:左侧侧栏(ProfilesNavigate)+ 右侧内容区(子路由) + * - 提供 URL search 参数验证(支持从 URL 传递订阅链接) + * - 通过 AnimatedOutletPreset 渲染子路由过渡动画 + * + * 当前阶段(Profiles Step 2 — 侧栏导航迁移): + * - 添加 Sidebar 双栏布局,匹配 ref 的 profiles/route.tsx + * - 左侧 SidebarContent 显示 ProfilesNavigate(按配置类型导航) + * - 右侧使用 AppContentScrollArea + AnimatedOutletPreset + * - 保留 validateSearch 验证 subscribeUrl/subscribeName/subscribeDesc + * + * 后续迁移计划: + * - 添加 $type 子路由(按 ProfileType 过滤配置列表) + * - 添加 inspect 子路由(配置内省) + */ + +import { cn } from '@chimera/ui'; +import { createFileRoute } from '@tanstack/react-router'; +import { AnimatedOutletPreset } from '@/components/router/animated-outlet'; +import { AppContentScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import ProfilesNavigate from './_modules/profiles-navigate'; + +/** + * URL search 参数类型定义 + * 支持从 URL 传递订阅链接参数(用于 AddProfileDialog 预填) + */ +type ProfilePageSearch = { + subscribeName?: string; + subscribeUrl?: string; + subscribeDesc?: string; +}; + +/** + * 验证并转换可选的字符串参数 + */ +const asOptionalString = (value: unknown) => { + return typeof value === 'string' && value ? value : undefined; +}; + +/** + * 验证并转换 URL 参数(必须是合法的 URL) + */ +const asValidUrl = (value: unknown) => { + if (typeof value !== 'string' || !value) { + return undefined; + } + + try { + new URL(value); + return value; + } catch { + return undefined; + } +}; + +export const Route = createFileRoute('/(main)/main/profiles')({ + // 验证 search 参数:支持 subscribeUrl / subscribeName / subscribeDesc + validateSearch: (search): ProfilePageSearch => { + const subscribeUrl = asValidUrl(search.subscribeUrl); + + return { + subscribeUrl, + subscribeName: asOptionalString(search.subscribeName), + subscribeDesc: asOptionalString(search.subscribeDesc), + }; + }, + component: RouteComponent, +}); + +/** + * Profiles 页面布局组件 + * + * 使用 Sidebar 组件提供双栏布局(匹配 ref): + * - 左侧:配置类型导航列表(SidebarContent + ProfilesNavigate) + * - 右侧:配置列表内容区(AppContentScrollArea + AnimatedOutletPreset) + */ +function RouteComponent() { + return ( + + {/* 左侧侧边栏:配置类型导航 */} + + + + + {/* + 右侧内容区域:配置列表 + 使用 AppContentScrollArea 提供统一的滚动管理 + */} + +
+ +
+
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/$name.tsx b/frontend/chimera/src/pages/(main)/main/proxies/$name.tsx new file mode 100644 index 00000000..196acd95 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/$name.tsx @@ -0,0 +1,254 @@ +/** + * 代理组详情页(子路由) + * + * 迁移自 ref: `src/pages/(main)/main/proxies/group/$name.tsx` + *(路由结构调整:ref 使用 `group/$name` 独立路由,本版本改为 `proxies/$name` 子路由) + * + * 职责: + * - 作为 `/main/proxies` 的子路由,在 Sidebar 布局的右侧内容区显示 + * - 根据路由参数 `name` 查找并显示指定代理组的节点列表 + * - 使用 @tanstack/react-virtual 多列虚拟化网格布局(lanes) + * - 提供延迟测试、节点选择、组流量显示等功能 + * + * 当前阶段(Proxies Step 3 — 组详情迁移至 ref): + * - 替换 virtua 的 NodeList 为 @tanstack/react-virtual 多列网格(lanes) + * - 替换 MUI DelayButton 为 ref 的 DelayTestButton(含加载动画、成功反馈) + * - 添加 GroupHeader(组名 + 流量统计 + 滚动到当前节点) + * - 添加 ProxyNodeButton(单节点选择 + 延迟测试) + * - 使用 useContainerBreakpointValue 响应式调整列数(xs~xl) + * - 使用 useCurrentGroupConnection 显示组流量(download/upload) + * + * 已完成: + * - Step 4: 搜索/过滤功能(通过父路由 validateSearch 传入 searchQuery,已实现并应用于 filteredProxies) + * + * 后续计划: + * - 添加排序功能(按延迟或名称) + */ + +import { + useClashProxies, + useProxyMode, + type ClashProxiesQueryGroupItem, +} from '@chimera/interface'; +import { useContainerBreakpointValue } from '@chimera/ui'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import ArrowDownwardAltRounded from '~icons/material-symbols/arrow-downward-alt-rounded'; +import ArrowUpwardAltRounded from '~icons/material-symbols/arrow-upward-alt-rounded'; +import Radar from '~icons/material-symbols/radar'; +import { filesize } from 'filesize'; +import { useCallback, useMemo, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { useScrollArea } from '@/components/ui/scroll-area'; +import DelayTestButton from './_modules/delay-test-button'; +import GroupHeader from './_modules/group-header'; +import { useCurrentGroupConnection } from './_modules/hooks'; +import ProxyNodeButton from './_modules/proxy-node-button'; + +/** + * 注册 TanStack Router 文件路由 + * 此文件位于 `proxies/$name.tsx`,作为 `/(main)/main/proxies` 的子路由, + * 路径模板: `/main/proxies/$name` + * + * 子路由会被渲染在 parent route.tsx 的 `` / `` 中, + * 因此组详情会显示在 Sidebar 的右侧内容区域。 + */ +export const Route = createFileRoute('/(main)/main/proxies/$name')({ + component: RouteComponent, +}); + +/** + * 代理组详情页组件 + * + * 布局结构(匹配 ref): + * - GroupHeader:sticky 头部(组名 + 流量统计 + 滚动到当前节点按钮) + * - Virtualized Grid:多列虚拟化网格(每个 ProxyNodeButton) + * - DelayTestButton:浮动延迟测试按钮 + * + * 搜索过滤(Step 4): + * - 通过 useSearch({ from: '/(main)/main/proxies' }) 读取父路由的 searchQuery + * - 基于 searchQuery 对 currentGroup.all 进行大小写不敏感的模糊匹配 + * - 过滤后的列表用于虚拟滚动,搜索时自动调整 virtualizer 的 count + * - 在 GroupHeader 旁显示搜索结果计数("共 N 个节点") + */ +function RouteComponent() { + // 从路由参数中获取代理组名称 + const { name: proxyGroupName } = Route.useParams(); + + // 从父路由读取 searchQuery 搜索参数 + const { searchQuery } = useSearch({ from: '/(main)/main/proxies' }); + + const { + proxies: { data: proxies }, + } = useClashProxies(); + + const { value: proxyMode } = useProxyMode(); + + // 查找当前选中的代理组(或 global 模式的 special 组) + const currentGroup = useMemo(() => { + if (proxyMode.global) { + return proxies?.global; + } + + return proxies?.groups.find((group) => group.name === proxyGroupName); + }, [proxies, proxyGroupName, proxyMode]); + + // 搜索过滤:基于 searchQuery 对节点名称进行大小写不敏感的模糊匹配 + const filteredProxies = useMemo(() => { + const all = currentGroup?.all; + + if (!all || !all.length) { + return []; + } + + if (!searchQuery) { + return all; + } + + const lowerQuery = searchQuery.toLowerCase(); + + return all.filter( + (proxy) => + proxy.name.toLowerCase().includes(lowerQuery) || + // 也匹配节点类型(如 Shadowsocks、Vmess 等) + proxy.type?.toLowerCase().includes(lowerQuery), + ); + }, [currentGroup?.all, searchQuery]); + + // 获取 AppContentScrollArea 的 viewportRef(用于同步虚拟滚动) + const { viewportRef } = useScrollArea(); + + // 响应式列数:根据容器宽度自动调整网格列数(匹配 ref) + const lanes = useContainerBreakpointValue( + viewportRef, + { + xs: 2, + sm: 3, + md: 4, + lg: 5, + xl: 6, + }, + 4, + ); + + // 初始化 @tanstack/react-virtual 多列虚拟化 + // 注意:count 使用 filteredProxies.length 而非 currentGroup.all.length + const virtualizer = useVirtualizer({ + count: filteredProxies.length, + getScrollElement: () => viewportRef.current, + estimateSize: () => 60, + overscan: 5, + lanes, + measureElement: (element) => element?.getBoundingClientRect().height, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + // 滚动到当前选中节点(便于用户在列表中快速定位) + // 注意:在 filteredProxies 中搜索当前节点索引,而非 currentGroup.all + const handleScrollToCurrentNode = useCallback(() => { + const index = filteredProxies.findIndex( + (proxy) => proxy.name === currentGroup?.now, + ); + + if (index !== undefined && index >= 0) { + virtualizer.scrollToIndex(index, { + align: 'center', + behavior: 'smooth', + }); + } + }, [filteredProxies, currentGroup?.now, virtualizer]); + + // 获取经过当前组的连接流量(用于显示 download/upload 速率) + const currentGroupConnection = useCurrentGroupConnection(currentGroup); + + return ( + <> + {/* + GroupHeader:sticky 头部 + 包含组名称、流量统计、滚动到当前节点按钮 + */} + +
+
{currentGroup?.name}
+ + {/* 搜索匹配数(仅在有搜索条件时显示) */} + {searchQuery && ( + + 匹配 {filteredProxies.length} / {currentGroup?.all?.length ?? 0}{' '} + 个节点 + + )} + + {/* 下载流量 */} +
+ + + + {filesize(currentGroupConnection?.download ?? 0)}/s + +
+ + {/* 上传流量 */} +
+ + + + {filesize(currentGroupConnection?.upload ?? 0)}/s + +
+
+ +
+ + {/* 滚动到当前节点按钮 */} + + + + {/* + 虚拟化网格容器 + 使用 @tanstack/react-virtual 的多列 lanes 布局 + */} +
+ {virtualItems.map((virtualItem) => { + // 使用 filteredProxies 而非 currentGroup.all,以支持搜索过滤 + const proxy = filteredProxies[virtualItem.index]; + + if (!proxy) { + return null; + } + + return ( +
+ +
+ ); + })} +
+ + {/* 浮动延迟测试按钮 */} + + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/_modules/delay-test-button.tsx b/frontend/chimera/src/pages/(main)/main/proxies/_modules/delay-test-button.tsx new file mode 100644 index 00000000..f1057c2b --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/_modules/delay-test-button.tsx @@ -0,0 +1,102 @@ +/** + * 代理组延迟测试浮动按钮 + * + * 迁移自 ref: `src/pages/(main)/main/proxies/group/_modules/delay-test-button.tsx` + * + * 职责: + * - 浮动在内容区右下角的 FAB,点击触发当前组的全部延迟测试 + * - 使用 useBlockTask 管理加载/成功状态 + * - 测试完成后显示 1 秒绿色成功效果 + * - 使用 Tooltip 显示按钮功能说明 + * + * 当前阶段(Proxies Step 3 — 组详情迁移): + * - 匹配 ref 的 DelayTestButton 设计(含加载动画、成功状态、毛玻璃效果) + * - 使用 MUI Tooltip 替代 ref 的自定义 Tooltip 组件(Chimera 生态) + * - 使用 unplugin-icons 导入 BoltRounded 图标 + * + * 后续计划: + * - 配合 useScrollArea 的 content-bottom 检测,动态调整按钮位置 + */ + +import { useClashProxies } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { Tooltip } from '@mui/material'; +import BoltRounded from '~icons/material-symbols/bolt-rounded'; +import { useState } from 'react'; +import { useBlockTask } from '@/components/providers/block-task-provider'; +import { Button } from '@/components/ui/button'; +import { useLockFn } from '@/hooks/use-lock-fn'; +import * as m from '@/paraglide/messages'; +import { Route as NameRoute } from '../$name'; + +/** + * 延迟测试浮动按钮 + * + * 使用 updateGroupDelay mutation 触发全部节点延迟测试, + * 完成后通过短暂的成功状态(isSuccess)反馈给用户。 + */ +export default function DelayTestButton() { + const { name } = NameRoute.useParams(); + + const { updateGroupDelay } = useClashProxies(); + + const [isSuccess, setIsSuccess] = useState(false); + + const blockTask = useBlockTask(`delay-group-test-${name}`, async () => { + await updateGroupDelay.mutateAsync([name]); + }); + + const handleClick = useLockFn(async () => { + await blockTask.execute(); + + // 成功效果:显示 1 秒绿色反馈 + setIsSuccess(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSuccess(false); + }); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/_modules/group-header.tsx b/frontend/chimera/src/pages/(main)/main/proxies/_modules/group-header.tsx new file mode 100644 index 00000000..ad9202c8 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/_modules/group-header.tsx @@ -0,0 +1,73 @@ +/** + * 代理组头部组件 + * + * 迁移自 ref: `src/pages/(main)/main/proxies/group/_modules/group-header.tsx` + * + * 职责: + * - 固定在内容区顶部(sticky),显示当前代理组信息 + * - 移动端显示返回按钮(链接到 /main/proxies) + * - 子元素插槽显示组名称、流量统计、滚动到当前节点按钮等 + * + * 当前阶段(Proxies Step 3 — 组详情迁移): + * - 匹配 ref 的 sticky 头部设计 + * - 使用 group-data-[scroll-direction] 切换 padding(配合后续滚动方向检测) + * - BackButton 仅在移动端渲染(md:hidden) + * + * 后续计划: + * - 配合 useScrollArea 的滚动方向检测,动态调整 padding + */ + +import { cn } from '@chimera/ui'; +import { Link } from '@tanstack/react-router'; +import ArrowBackIosNewRounded from '~icons/material-symbols/arrow-back-ios-new-rounded'; +import type { ComponentProps } from 'react'; +import { Button } from '@/components/ui/button'; + +/** + * 返回按钮(仅移动端显示) + */ +const BackButton = () => { + return ( + + ); +}; + +/** + * 代理组头部容器 + * + * 使用 sticky 定位固定在内容区顶部, + * children 插槽中通常包含: + * - 组名称 + * - 下载/上传流量速率 + * - 滚动到当前节点按钮 + */ +export default function GroupHeader({ + children, + className, + ...props +}: ComponentProps<'div'>) { + return ( +
+ + + {children} +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/_modules/hooks.ts b/frontend/chimera/src/pages/(main)/main/proxies/_modules/hooks.ts new file mode 100644 index 00000000..1e625349 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/_modules/hooks.ts @@ -0,0 +1,44 @@ +/** + * Proxies 模块共享 Hooks + * + * 迁移自 ref: `src/pages/(main)/main/proxies/_modules/hooks.ts` + * + * 职责: + * - useCurrentGroupConnection:查找经过当前代理组(链)的连接 + * 用于在 GroupHeader 中显示当前组的实时流量(下载/上传速度) + */ + +import { + useClashConnections, + type ClashProxiesQueryGroupItem, +} from '@chimera/interface'; +import { useMemo } from 'react'; + +/** + * 查找经过当前代理组的活跃连接 + * + * 遍历所有连接快照的最后一个(最新), + * 找到 chains 中经过当前组名称的连接。 + * + * @param currentGroup - 当前选中的代理组 + * @returns 经过当前组的连接对象,或 undefined + */ +export function useCurrentGroupConnection( + currentGroup?: ClashProxiesQueryGroupItem, +) { + const { + query: { data: clashConnections }, + } = useClashConnections(); + + return useMemo(() => { + if (!currentGroup?.name) { + return undefined; + } + + return clashConnections + ?.at(-1) + ?.connections?.find((connection) => + connection.chains.includes(currentGroup?.name), + ); + }, [clashConnections, currentGroup?.name]); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxies-navigate.tsx b/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxies-navigate.tsx new file mode 100644 index 00000000..9b8001d9 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxies-navigate.tsx @@ -0,0 +1,87 @@ +/** + * 代理组导航侧栏 + * + * 迁移自 ref: `src/pages/(main)/main/proxies/_modules/proxies-navigate.tsx` + * + * 职责: + * - 在桌面端侧边栏中展示所有代理组列表 + * - 每个组可作为链接点击,导航到 `/main/proxies/group/$name` + * - 当组有图标时显示图标 + * + * 当前阶段(Step 2): + * - 使用现有的 Chimera GroupList 组件渲染组列表 + * - 后续可逐步替换为 ref 的虚拟化列表实现 + */ + +import { useClashProxies } from '@chimera/interface'; +import { cn, LazyImage } from '@chimera/ui'; +import { Link, useLocation } from '@tanstack/react-router'; +import { Button } from '@/components/ui/button'; + +/** + * 代理组导航组件 + * + * 渲染在 SidebarContent 中,显示所有代理组列表。 + * 每个组是一个按钮,点击跳转到对应的组详情页。 + */ +export default function ProxiesNavigate() { + const { + proxies: { data: proxies }, + } = useClashProxies(); + + const location = useLocation(); + + return ( +
+ {proxies?.groups.map((group) => ( + + ))} +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxy-node-button.tsx b/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxy-node-button.tsx new file mode 100644 index 00000000..bd3f02b3 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/_modules/proxy-node-button.tsx @@ -0,0 +1,139 @@ +/** + * 代理节点按钮组件 + * + * 迁移自 ref: `src/pages/(main)/main/proxies/group/_modules/proxy-node-button.tsx` + * + * 职责: + * - 显示单个代理节点的名称和延迟测试结果 + * - 点击节点可切换选择(mutateSelect) + * - 右侧嵌入的延迟按钮可单独测试该节点延迟(mutateDelay) + * - 延迟结果使用 DelayChip 显示(绿色/黄色/红色) + * + * 当前阶段(Proxies Step 3 — 组详情迁移): + * - 匹配 ref 的 ProxyNodeButton 设计(含 per-node 延迟测试) + * - 使用 useBlockTask 管理单个节点的延迟测试状态 + * - 使用 DelayChip 组件显示延迟值(复用了 Chimera 现有组件但 API 不同, + * 此处直接使用 Button + 自定义延迟显示以匹配 ref 设计) + * - 当前节点高亮使用 data-active 属性(匹配 ref 的 group-data-[active] 模式) + * + * 后续计划: + * - 考虑复用 ref 的 DelayChip 设计,或统一 Chimera 的 DelayChip 组件 + */ + +import { type ClashProxiesQueryProxyItem } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import FlashOnRounded from '~icons/material-symbols/flash-on-rounded'; +import { useMemo, type ComponentProps, type MouseEvent } from 'react'; +import { useBlockTask } from '@/components/providers/block-task-provider'; +import { Button } from '@/components/ui/button'; +import { useLockFn } from '@/hooks/use-lock-fn'; + +/** + * 延迟测试结果的背景色映射 + * + * 匹配 ref 的延迟阈值设计: + * - <= 200ms:绿色(优秀) + * - <= 500ms:黄色(良好) + * - > 500ms:红色(较差) + * - -1 或未测试:默认 + */ +function delayColorClass(delay: number): string { + if (delay <= 0) return ''; // 未测试 + if (delay <= 200) return 'bg-green-500/20 dark:bg-green-700/30'; + if (delay <= 500) return 'bg-yellow-500/20 dark:bg-yellow-700/30'; + return 'bg-red-500/20 dark:bg-red-700/30'; +} + +/** + * 代理节点按钮 + * + * 点击节点主体 → 切换选择该节点(mutateSelect) + * 点击延迟按钮 → 测试该节点延迟(mutateDelay) + * + * @param props.proxy - 代理节点数据 + */ +export default function ProxyNodeButton({ + proxy, + ...props +}: Omit, 'onClick' | 'children'> & { + proxy: ClashProxiesQueryProxyItem; +}) { + // 切换选择当前节点 + const handleSelectProxy = useLockFn(async () => { + await proxy.mutateSelect(); + }); + + // 单节点延迟测试 + const delayTask = useBlockTask( + `proxy-delay-check-${proxy.name.toLowerCase()}`, + async () => { + await proxy.mutateDelay(); + }, + ); + + // 延迟按钮点击:阻止冒泡(不触发节点选择),执行延迟测试 + const handleDelayClick = useLockFn( + async (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + await delayTask.execute(); + }, + ); + + // 获取最新延迟历史 + const currentDelay = useMemo(() => { + if (!proxy.history || proxy.history.length === 0) { + return -1; + } + return proxy.history[proxy.history.length - 1].delay; + }, [proxy.history]); + + return ( + +
+ + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/index.tsx b/frontend/chimera/src/pages/(main)/main/proxies/index.tsx new file mode 100644 index 00000000..ed560aff --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/index.tsx @@ -0,0 +1,41 @@ +/** + * 代理页面首页(移动端代理组列表) + * + * 迁移自 ref: `src/pages/(main)/main/proxies/index.tsx` + * + * 职责: + * - 在移动端显示代理组导航列表 + * - 桌面端返回 null(组列表已在侧边栏中显示) + * + * 路由逻辑: + * - 桌面端(!isMobile):挂载此路由时返回 null,由 route.tsx 的 Sidebar 负责渲染组列表 + * - 移动端(isMobile):显示 ProxiesNavigate 作为独立页面 + */ + +import { createFileRoute } from '@tanstack/react-router'; +import { AppContentScrollArea } from '@/components/ui/scroll-area'; +import useIsMobile from '@/hooks/use-is-moblie'; +import ProxiesNavigate from './_modules/proxies-navigate'; + +export const Route = createFileRoute('/(main)/main/proxies/')({ + component: RouteComponent, +}); + +function RouteComponent() { + const isMobile = useIsMobile(); + + // 桌面端:侧边栏已在 route.tsx 中显示,这里不重复渲染 + if (!isMobile) { + return null; + } + + // 移动端:显示代理组列表作为独立页面 + return ( + + + + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/proxies/route.tsx b/frontend/chimera/src/pages/(main)/main/proxies/route.tsx new file mode 100644 index 00000000..6bc2a347 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/proxies/route.tsx @@ -0,0 +1,245 @@ +/** + * 代理页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/proxies/route.tsx` + * + * 职责: + * - 提供双栏布局:左侧代理组列表 + 右侧代理节点详情 + * - 左侧 SidebarContent 显示 ProxiesNavigate(代理组导航) + * - 右侧内容区域通过 AnimatedOutletPreset 渲染组详情 + * - 空状态:无代理时显示 Empty 提示(直连模式引导切换 / 空组引导添加订阅) + * - 搜索功能:通过 validateSearch 解析 searchQuery 参数,传递给子路由 + * + * 当前阶段(Step 4 — 添加搜索功能): + * - searchQuery 从 validateSearch 解析,绑定到 URL search 参数 + * - 内容区顶部显示搜索输入框,输入即搜索(实时更新 URL) + * - 子路由 $name.tsx 通过 useSearch({ from: parentRouteId }) 读取 searchQuery + * - 搜索框使用防抖处理(200ms)避免 URL 更新过于频繁 + * + * 已完成阶段回顾: + * - Step 1: 双栏布局 + 组导航 + AnimatedOutletPreset + * - Step 2: searchQuery validateSearch + Empty + AppContentScrollArea + proxyMode + * - Step 3: @tanstack/react-virtual 多列网格 + GroupHeader + ProxyNodeButton + */ + +import { ProxyMode, useClashProxies, useProxyMode } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; +import BoxOutlineRounded from '~icons/material-symbols/box-outline-rounded'; +import DirectionsRunRounded from '~icons/material-symbols/directions-run-rounded'; +import Search from '~icons/material-symbols/search'; +import { useCallback, useRef, useState } from 'react'; +import { AnimatedOutletPreset } from '@/components/router/animated-outlet'; +import { Button } from '@/components/ui/button'; +import { AppContentScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import { useLockFn } from '@/hooks/use-lock-fn'; +import * as m from '@/paraglide/messages'; +import ProxiesNavigate from './_modules/proxies-navigate'; + +/** + * URL search 参数验证 + * 支持 `?searchQuery=keyword` 过滤代理组节点 + * + * 匹配 ref 的 zodSearchValidator 但使用手动 validateSearch 维护(无 zod 依赖) + */ +export const Route = createFileRoute('/(main)/main/proxies')({ + component: RouteComponent, + validateSearch: (search: Record) => ({ + searchQuery: + typeof search.searchQuery === 'string' ? search.searchQuery : undefined, + }), +}); + +/** + * 空状态组件 + * + * 在以下时显示: + * - 直连模式(Direct mode):引导用户切换至 Rule/Global 模式 + * - 无代理组(空组):引导用户添加订阅配置 + * + * 匹配 ref 的 Empty 组件实现: + * - 直连模式:显示 DirectionsRunRounded 图标 + 切换按钮(Rule / Global) + * - 空组:显示 BoxOutlineRounded 图标 + "添加代理"按钮(跳转 Profiles 页面) + */ +const Empty = () => { + const proxyMode = useProxyMode(); + + const Icon = proxyMode.value.direct + ? DirectionsRunRounded + : BoxOutlineRounded; + + const handleSwitchMode = useLockFn(async (mode: ProxyMode) => { + await proxyMode.upsert(mode); + }); + + return ( +
+ + +

+ {proxyMode.value.direct + ? m.proxies_group_direct_message() + : m.proxies_group_empty_message()} +

+ + {proxyMode.value.direct ? ( +
+ + + +
+ ) : ( + + )} +
+ ); +}; + +/** + * 代理页面布局组件 + * + * 使用 Sidebar 组件提供双栏布局: + * - 左侧:代理组导航列表(SidebarContent + ProxiesNavigate) + * - 右侧:代理节点详情(AppContentScrollArea + 搜索框 + AnimatedOutletPreset) + * + * 搜索功能: + * - 在右侧内容区顶部显示搜索输入框 + * - 输入即时更新 URL searchQuery 参数(通过 navigate) + * - 子路由 $name.tsx 读取 searchQuery 过滤节点 + * + * 条件显示: + * - 直连模式:隐藏侧栏 + Empty 空状态 + * - 无代理组:隐藏侧栏 + Empty 空状态 + * - Rule/Script 模式且有组:侧栏 + 内容区(含搜索框) + */ +function RouteComponent() { + const { + proxies: { data: proxies }, + } = useClashProxies(); + + // 检查无代理组的情况 + const isNoProxies = !proxies?.groups?.length || proxies?.groups?.length === 0; + + const proxyMode = useProxyMode(); + + // 搜索功能 + const { searchQuery } = Route.useSearch(); + const navigate = useNavigate(); + // 防抖定时器引用,匹配 codebase 中 `as never` 的 search 参数风格 + const debounceRef = useRef | undefined>( + undefined, + ); + + const handleSearchChange = useCallback( + (value: string) => { + // 防抖 200ms,避免每次输入都更新 URL + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + navigate({ + search: (prev: { searchQuery?: string }) => ({ + ...prev, + searchQuery: value || undefined, + }), + // TanStack Router 的 navigate search 类型较严格, + // 使用 `as never` 匹配 codebase 模式(参见 scheme-provider.tsx:56) + } as never); + }, 200); + }, + [navigate], + ); + + return ( + + {/* 左侧侧边栏:代理组列表(仅在 Rule/Script 模式且有组时显示) */} + {!isNoProxies && (proxyMode.value.rule || proxyMode.value.script) && ( + + + + )} + + {/* + 右侧内容区域:代理节点详情 + 使用 AppContentScrollArea 匹配 ref 的滚动管理方式 + 空状态或直连模式时显示 Empty 组件 + */} + + {isNoProxies || proxyMode.value.direct ? ( + + ) : ( +
+ {/* 搜索输入框 — 仅在非空状态时显示 */} +
+
+ + + handleSearchChange(e.target.value)} + data-slot="proxies-search-input" + /> +
+
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/rules/_modules/proxy-icon.tsx b/frontend/chimera/src/pages/(main)/main/rules/_modules/proxy-icon.tsx new file mode 100644 index 00000000..aa8dda22 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/rules/_modules/proxy-icon.tsx @@ -0,0 +1,53 @@ +/** + * 代理图标组件 + * + * 迁移自 ref: `src/pages/(main)/main/rules/_modules/proxy-icon.tsx` + * + * 职责: + * - 根据代理组名称显示对应的图标 + * - 如果有 icon URL 则使用 CacheImage 渲染 + * - 否则显示代理组名称的前两个大写字母作为 fallback + * + * 用于规则页面的侧栏代理过滤列表,以及连接页面的侧栏代理过滤列表。 + */ + +import { useClashProxies } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { useMemo } from 'react'; + +/** + * 代理图标组件 + * + * @param groupName - 代理组名称 + * @returns 代理图标(图片或文字 fallback) + */ +export default function ProxyIcon({ groupName }: { groupName: string }) { + const { + proxies: { data: proxies }, + } = useClashProxies(); + + // 从代理数据中查找对应组的 icon + const icon = useMemo(() => { + const proxyInfo = proxies?.groups.find((p) => p.name === groupName); + + return proxyInfo?.icon; + }, [groupName, proxies]); + + // 如果有 icon URL 则渲染图片,否则显示文字 fallback + return icon ? ( + {groupName} + ) : ( +
+ {groupName?.toLocaleUpperCase().slice(0, 2)} +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/rules/index.tsx b/frontend/chimera/src/pages/(main)/main/rules/index.tsx new file mode 100644 index 00000000..4a1d2616 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/rules/index.tsx @@ -0,0 +1,252 @@ +/** + * 规则页面 + * + * 迁移自 ref: `src/pages/(main)/main/rules/index.tsx` + * + * 职责: + * - 显示所有 Clash 规则 + * - 支持按关键字过滤规则(类型、负载、代理) + * - 支持按代理过滤规则(通过 URL search params + 侧栏) + * - 使用 @tanstack/react-table 渲染表格 + * - 使用 @tanstack/react-virtual 虚拟化大量规则 + * + * 当前阶段(Rules Step 2 - 迁移至 ref 实现): + * - 使用 @tanstack/react-table + @tanstack/react-virtual 替代 virtua + * - 使用 URL search params 驱动代理过滤(与 route.tsx 配合) + * - 搜索过滤规则(类型、负载、代理) + * - 表格列:Index / Type / Payload / Proxy + * - 使用 useScrollArea 获取 viewportRef 供 Virtualizer 使用 + * + * 后续迁移计划: + * - 添加列排序功能 + * - 添加 HighlightText 搜索高亮组件 + */ + +import { useClashRules } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { createFileRoute } from '@tanstack/react-router'; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useMemo, useRef, useState } from 'react'; +import * as m from '@/paraglide/messages'; +import { Route as RulesRoute } from './route'; + +export const Route = createFileRoute('/(main)/main/rules/')({ + component: RouteComponent, +}); + +/** + * 规则内容查看器(Viewer) + * + * 负责: + * - 从 URL search params 获取 proxy 过滤条件 + * - 从搜索框获取搜索关键词 + * - 使用 @tanstack/react-table 渲染规则表格 + * - 使用 @tanstack/react-virtual 虚拟化大量行 + */ +function Viewer({ search }: { search: string }) { + // 从父路由获取 proxy 过滤条件 + const { proxy } = RulesRoute.useSearch(); + + // 滚动容器 ref(供 Virtualizer 使用) + const scrollRef = useRef(null); + + // 获取规则数据 + const { data } = useClashRules(); + + /** + * 过滤规则: + * 1. 按代理过滤(来自 URL search params) + * 2. 按搜索关键词过滤(类型、负载、代理) + */ + const filteredRules = useMemo(() => { + const rules = data?.rules ?? []; + + // 按代理过滤 + const proxyFilteredRules = proxy + ? rules.filter((rule) => rule.proxy === proxy) + : rules; + + // 按搜索关键词过滤 + if (!search.trim()) { + return proxyFilteredRules; + } + + const searchLower = search.toLowerCase(); + + return proxyFilteredRules.filter((rule) => { + return ( + rule.type?.toLowerCase().includes(searchLower) || + rule.payload?.toLowerCase().includes(searchLower) || + rule.proxy?.toLowerCase().includes(searchLower) + ); + }); + }, [data?.rules, proxy, search]); + + // 初始化 @tanstack/react-table + const table = useReactTable({ + data: filteredRules, + columns: [ + { + accessorKey: 'Index', + header: 'Index', + cell: (info) => info.row.index + 1, + size: 80, + }, + { + accessorKey: 'type', + header: 'Type', + cell: (info) => ( + {info.row.original.type || ''} + ), + size: 160, + }, + { + accessorKey: 'payload', + header: 'Payload', + cell: (info) => ( + + {info.row.original.payload || ''} + + ), + size: 400, + }, + { + accessorKey: 'proxy', + header: 'Proxy', + cell: (info) => ( + {info.row.original.proxy || ''} + ), + size: 160, + }, + ], + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const { rows } = table.getRowModel(); + + // 初始化 @tanstack/react-virtual 虚拟滚动 + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 48, + overscan: 10, + measureElement: (element) => element?.getBoundingClientRect().height, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + return ( +
+
+ + {/* 列宽定义 */} + + + + + + + + + {virtualItems.map((virtualRow, index) => { + const row = rows[virtualRow.index]; + + if (!row) { + return null; + } + + const offset = virtualRow.start - index * virtualRow.size; + + return ( + + {row.getVisibleCells().map(({ column, id, getContext }) => ( + + ))} + + ); + })} + +
+ {flexRender(column.columnDef.cell, getContext())} +
+
+
+ ); +} + +/** + * 规则页面主组件 + * + * 布局: + * - 可滚动区域:Viewer(虚拟化规则表格) + * - 底部工具栏:搜索框 + */ +function RouteComponent() { + const [search, setSearch] = useState(''); + + return ( +
+ {/* 规则表格内容区域 */} + + + {/* + 底部工具栏:搜索框 + 支持按类型、负载、代理名称过滤 + */} +
+ setSearch(e.target.value)} + /> +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/rules/route.tsx b/frontend/chimera/src/pages/(main)/main/rules/route.tsx new file mode 100644 index 00000000..d23fbe03 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/rules/route.tsx @@ -0,0 +1,167 @@ +/** + * 规则页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/rules/route.tsx` + * + * 职责: + * - 作为 `/main/rules` 路由的父容器 + * - 提供左侧代理过滤侧栏(ProxySelector) + * - 通过 URL search 参数 `?proxy=GroupName` 过滤规则 + * - 使用 Outlet 渲染子路由(规则列表页) + * + * 当前阶段(Rules Step 2 - 迁移至 ref 实现): + * - 添加 validateSearch 验证 proxy 参数 + * - 使用 Chimera 的 Sidebar 组件替代 ref 的 SliderSidebar + * - 侧栏显示所有去重的代理名称(从规则数据中提取) + * - 使用 ProxyIcon 显示每个代理的图标 + * - 选中的代理通过 URL search params 反映 + */ + +import { useClashRules } from '@chimera/interface'; +import { cn } from '@chimera/ui'; +import { Tooltip } from '@mui/material'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import * as m from '@/paraglide/messages'; +import ProxyIcon from './_modules/proxy-icon'; + +/** + * URL search 参数验证 + * 支持 `?proxy=GroupName` 过滤规则 + */ +export const Route = createFileRoute('/(main)/main/rules')({ + component: RouteComponent, + validateSearch: (search: Record) => ({ + proxy: typeof search.proxy === 'string' ? search.proxy : undefined, + }), +}); + +/** + * 代理过滤项 + * 每个规则中的代理(如 Proxy、Reject、Direct 等)作为一个过滤条目 + * 选中时通过 URL search param `proxy=GroupName` 高亮并过滤 + */ +function ProxyFilterItem({ + item, + children, +}: { + item?: string; + children: string; +}) { + const { proxy } = Route.useSearch(); + + const isActive = item === proxy; + + return ( + + + + ); +} + +/** + * 列表图标(用于「所有代理」条目) + * 使用 SVG 替代 ref 的 ListRounded 图标 + */ +function ListIcon() { + return ( + + + + ); +} + +/** + * 代理过滤器组件 + * + * 从 useClashRules 获取所有规则, + * 提取每个规则中的 proxy 字段(去重), + * 构建侧栏过滤列表。 + */ +const ProxySelector = () => { + const { data } = useClashRules(); + + // 从规则中提取所有去重的代理名称 + const allProxy = useMemo(() => { + const proxies = + data?.rules + .map((rule) => rule.proxy) + .filter((proxy): proxy is string => !!proxy) ?? []; + + return [...new Set(proxies)]; + }, [data]); + + return ( +
+ {/* "所有代理"条目(清除 proxy 过滤条件) */} + {m.rules_list_all_proxies()} + + {/* 每个代理作为一个过滤条目 */} + {allProxy.map((item) => ( + + {item} + + ))} +
+ ); +}; + +/** + * 规则页面布局组件 + * + * 布局结构(与 ref 一致): + * - 左侧侧栏:代理过滤列表(ProxySelector) + * - 右侧内容区:通过 Outlet 渲染规则列表页 + */ +function RouteComponent() { + return ( + + {/* 左侧侧栏:代理过滤列表 */} + + + + + + + {/* 右侧内容区:规则列表 */} +
+ +
+
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-card.tsx b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-card.tsx new file mode 100644 index 00000000..0e6d30a5 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-card.tsx @@ -0,0 +1,186 @@ +/** + * 设置页面卡片组件 + * + * 迁移自 ref: `src/pages/(main)/main/settings/_modules/settings-card.tsx` + * + * 职责: + * - 提供设置页面的卡片布局组件(Card、CardHeader、CardContent、CardFooter) + * - 提供设置项容器和标签组件(ItemContainer、ItemLabel、ItemLabelText、ItemLabelDescription) + * - 标签/描述分组组件(SettingsLabel、SettingsGroup) + * + * 适配说明: + * - Chimera 无 Card 组件封装,使用 div + Tailwind 类实现等效样式 + * - 移除了 ref 对 @nyanpasu/components Card 的依赖 + * - 移除了 ref 的 AnimatedItem(Chimera 无对应组件) + * - 圆角、内边距、颜色等视觉参数尽量匹配 ref + * - 使用 data-slot 属性标识组件角色,方便 QA 定位 + */ + +import { cn } from '@chimera/ui'; +import { type ComponentProps } from 'react'; + +/** + * SettingsLabel — 设置区域标签 + * 用于分隔不同设置区块的文本标签 + */ +export function SettingsLabel({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * SettingsGroup — 设置项分组容器 + * 将多个设置卡片或项组合在一起,自动调整相邻元素的圆角 + * - 唯一子元素:全部圆角 + * - 首个元素:上圆角,下平角 + * - 末个元素:下圆角,上平角 + * - 中间元素:无圆角 + */ +export function SettingsGroup({ className, ...props }: ComponentProps<'div'>) { + return ( +
*:first-child:not(:only-child)]:rounded-b-sm', + '[&>*:last-child:not(:only-child)]:rounded-t-sm', + '[&>*:not(:first-child):not(:last-child)]:rounded-sm', + className, + )} + data-slot="settings-group" + {...props} + /> + ); +} + +/** + * SettingsCard — 设置卡片 + * 使用 Card 语义的外观容器,包裹设置项 + */ +export function SettingsCard({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * SettingsCardHeader — 卡片头部 + * 通常包含标题或说明文字 + */ +export function SettingsCardHeader({ + className, + ...props +}: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * SettingsCardFooter — 卡片底部 + * 通常包含操作按钮或状态指示 + */ +export function SettingsCardFooter({ + className, + ...props +}: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * SettingsCardContent — 卡片内容区域 + * 包含设置项列表 + */ +export function SettingsCardContent({ + className, + ...props +}: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * ItemContainer — 设置项行容器 + * 使用 flex 布局将标签和操作控件分列两侧 + */ +export function ItemContainer({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * ItemLabel — 设置项标签容器 + * 包含标题文本和描述文本,垂直排列 + */ +export function ItemLabel({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ ); +} + +/** + * ItemLabelText — 设置项标题文本 + * 粗体,用于标识设置项名称 + */ +export function ItemLabelText({ className, ...props }: ComponentProps<'p'>) { + return ( +

+ ); +} + +/** + * ItemLabelDescription — 设置项描述文本 + * 灰度小字,用于说明设置项的功能 + */ +export function ItemLabelDescription({ + className, + ...props +}: ComponentProps<'p'>) { + return ( +

+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-navigate.tsx b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-navigate.tsx new file mode 100644 index 00000000..628b5105 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-navigate.tsx @@ -0,0 +1,248 @@ +/** + * 设置页面侧边栏导航 + * + * 迁移自 ref: `src/pages/(main)/main/settings/_modules/settings-navigate.tsx` + * + * 职责: + * - 在设置页面左侧侧边栏显示导航按钮列表 + * - 每个按钮对应一个设置子路由(系统、界面、Clash、Web UI、Nyanpasu、调试、关于) + * - 使用 Button asChild + TanStack Router Link 实现导航 + * - 使用 TextMarquee 显示描述文字(溢出时滚动) + * - 按钮高亮当前路由(根据 location.pathname 匹配) + * + * 适配说明: + * - 移除了 ref 的 LogoSvg(Chimera 无对应资源),使用内联 SVG 替代 + * - 移除了 ref 的 useCurrentCoreIcon(Chimera 无对应 hook),使用 Settings 图标替代 + * - 使用 Chimera 的 TextMarquee 组件(与 ref 功能一致) + * - 使用 Button 组件 variant="fab" + asChild 模式(支持) + */ + +import { cn } from '@chimera/ui'; +import { Link, useLocation } from '@tanstack/react-router'; +import DisplayExternalInput from '~icons/material-symbols/display-external-input-rounded'; +import FrameBugOutlineRounded from '~icons/material-symbols/frame-bug-outline-rounded'; +import SettingsBoltRounded from '~icons/material-symbols/settings-b-roll-rounded'; +import SettingsEthernet from '~icons/material-symbols/settings-ethernet-rounded'; +import SettingsRounded from '~icons/material-symbols/settings-rounded'; +import ViewQuilt from '~icons/material-symbols/view-quilt-rounded'; +import type { ComponentProps, ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import TextMarquee from '@/components/ui/text-marquee'; +import * as m from '@/paraglide/messages'; + +/** + * Chimera Logo(内联 SVG) + * 替代 ref 的 LogoSvg 导入,使用标准 Material Symbols 图标加品牌色 + */ +const ChimeraLogo = () => { + return ( + + {/* 圆形背景 */} + + {/* C 形图标(Chimera 首字母) */} + + + + ); +}; + +/** + * 导航按钮组件 + * 使用 Button asChild + Link 实现无样式丢失的路由导航 + * 自动根据当前路径高亮活动状态 + */ +const NavigateButton = ({ + icon, + label, + description, + className, + ...props +}: ComponentProps & { + icon: ReactNode; + label: string; + description: string; +}) => { + const location = useLocation(); + + const isActive = location.pathname === props.to; + + return ( + + ); +}; + +/* ========== 各子路由按钮 ========== */ + +/** 系统设置 */ +const SystemButton = () => { + return ( + } + label={m.settings_label_system()} + description={m.settings_label_system_description()} + to="/main/settings/system" + /> + ); +}; + +/** 界面设置 */ +const UserInterfaceButton = () => { + return ( + } + label={m.settings_label_user_interface()} + description={m.settings_label_user_interface_description()} + to="/main/settings/user-interface" + /> + ); +}; + +/** Clash 核心设置 */ +const ClashButton = () => { + return ( + } + label={m.settings_label_clash_settings()} + description={m.settings_label_clash_settings_description()} + to="/main/settings/clash" + /> + ); +}; + +/** Web UI / 外部控制设置 */ +const ExternalControllButton = () => { + return ( + + {/* 核心图标占位 — Chimera 无 useCurrentCoreIcon,使用 Settings 图标替代 */} + + + {/* 外部控制小角标 */} +

+ +
+
+ } + label={m.settings_label_external_controll()} + description={m.settings_label_external_controll_description()} + to="/main/settings/web-ui" + /> + ); +}; + +/** Nyanpasu(Chimera 专用)设置 */ +const NyanpasuButton = () => { + return ( + + + +
+ +
+
+ } + label={m.settings_label_nyanpasu()} + description={m.settings_label_nyanpasu_description()} + to="/main/settings/nyanpasu" + /> + ); +}; + +/** 调试设置 */ +const DebugButton = () => { + return ( + } + label={m.settings_label_debug()} + description={m.settings_label_debug_description()} + to="/main/settings/debug" + /> + ); +}; + +/** 关于页面 */ +const AboutButton = () => { + return ( + } + label={m.settings_label_about()} + description={m.settings_label_about_description()} + to="/main/settings/about" + /> + ); +}; + +/** + * SettingsNavigate — 设置页面侧边栏导航组件 + * + * 渲染 7 个导航按钮:系统、界面、Clash、Web UI、Nyanpasu、调试、关于 + * 排列方式:垂直列表,间距 gap-2 + */ +export default function SettingsNavigate() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-title.tsx b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-title.tsx new file mode 100644 index 00000000..3169fd03 --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/settings/_modules/settings-title.tsx @@ -0,0 +1,140 @@ +/** + * 设置页面粘性标题组件 + * + * 迁移自 ref: `src/pages/(main)/main/settings/_modules/settings-title.tsx` + * + * 职责: + * - 在设置子页面顶部显示粘性标题 + * - 滚动距离 < 40px 时:在页面内显示大标题(text-3xl) + * - 滚动距离 >= 40px 时:在 sticky header 中显示小标题(text-xl) + * - 使用 framer-motion 的 AnimatePresence + layout animation 实现平滑过渡 + * - 在移动端(md 以下)显示返回按钮,跳转至 /main/settings + * + * 实现说明: + * - 使用 useScrollArea().offset.top 获取滚动距离(与 ref 一致) + * - 两个标题区域使用共享的 useId 作为 layoutId,确保 framer-motion 识别为同一元素 + * - 动画时长和缓动曲线匹配 ref: 0.5s, [0.32, 0.72, 0, 1] + */ + +import { cn } from '@chimera/ui'; +import { Link } from '@tanstack/react-router'; +import ArrowBackIosNewRounded from '~icons/material-symbols/arrow-back-ios-new-rounded'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useId, type ComponentProps } from 'react'; +import { Button } from '@/components/ui/button'; +import { useScrollArea } from '@/components/ui/scroll-area'; + +/** + * 返回按钮 — 仅在移动端 (md 以下) 显示 + * 点击跳转至设置主页 /main/settings + */ +const BackButton = () => { + return ( + + ); +}; + +/** + * Animated Title — 使用 framer-motion 实现 layout 动画的标题文本 + * layoutId 与 useId 绑定,确保两个标题区域共享同一动画标识 + */ +const Title = (props: ComponentProps) => { + return ( + + ); +}; + +/** + * SettingsTitle — 设置页面粘性标题 + * + * @param children - 标题文本内容 + * + * 布局结构: + * 1. sticky header (h-16):始终固定在顶部,滚动 >= 40px 时显示小标题 + * 2. 页面内标题 (h-24):滚动 < 40px 时显示大标题 + * + * 使用 data-show-title 属性记录当前显示状态,方便外部样式选择 + */ +export function SettingsTitle({ + className, + children, + ...props +}: ComponentProps<'div'>) { + const { offset } = useScrollArea(); + + const id = useId(); + + const showTopTitle = offset.top > 40; + + return ( + <> + {/* sticky header — 滚动 >= 40px 时显示小标题 */} +
+ + + + {showTopTitle && ( + + {children} + + )} + +
+ + {/* 页面内标题 — 滚动 < 40px 时显示大标题 */} +
+ + {!showTopTitle && ( + + {children} + + )} + +
+ + ); +} diff --git a/frontend/chimera/src/pages/(main)/main/settings/index.tsx b/frontend/chimera/src/pages/(main)/main/settings/index.tsx new file mode 100644 index 00000000..8b8e697a --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/settings/index.tsx @@ -0,0 +1,54 @@ +/** + * 设置页面入口(默认子路由) + * + * 迁移自 ref: `src/pages/(main)/main/settings/index.tsx` + * + * 职责: + * - 作为 `/main/settings` 的默认子路由(在 AnimatedOutletPreset 中渲染) + * - 目前托管 legacy SettingPageComponent(全量设置页) + * - 后续迁移计划:逐步将各设置模块拆分为独立子路由,此页面最终对齐 ref: + * - 桌面端:返回 null(导航已由父路由 Sidebar 提供) + * - 移动端:显示 SettingsNavigate(全屏导航) + * + * 适配说明: + * - 移除了 AppContentScrollArea(父路由 route.tsx 已提供统一滚动管理) + * - 头部 GitHub/Feedback 按钮已移除(功能将在各子路由中分别实现) + * - 使用懒加载 SettingPageComponent 保持启动性能 + * - data-slot 属性标记便于自动化测试定位 + */ + +import { createFileRoute } from '@tanstack/react-router'; +import { lazy, Suspense } from 'react'; + +// 延迟加载设置页组件(较大,按需加载) +const SettingPageComponent = lazy( + () => import('@/components/setting/setting-page'), +); + +export const Route = createFileRoute('/(main)/main/settings/')({ + component: RouteComponent, +}); + +function RouteComponent() { + // 后续迁移:匹配 ref 模式 + // 桌面端 return null(父路由已提供侧栏导航) + // 移动端 return (全屏导航) + // + // 当前阶段:子路由尚未迁移完毕,保持 legacy 页面可访问 + // 待 `/settings/system` `/settings/clash` 等子路由全部迁移后, + // 切换至 ref 的 index 逻辑(桌面 null + 移动端 SettingsNavigate) + + return ( +
+ + Loading... +
+ } + > + + +
+ ); +} diff --git a/frontend/chimera/src/pages/(main)/main/settings/route.tsx b/frontend/chimera/src/pages/(main)/main/settings/route.tsx new file mode 100644 index 00000000..b8303e3b --- /dev/null +++ b/frontend/chimera/src/pages/(main)/main/settings/route.tsx @@ -0,0 +1,57 @@ +/** + * 设置页面父路由 + * + * 迁移自 ref: `src/pages/(main)/main/settings/route.tsx` + * + * 职责: + * - 提供双栏布局:左侧侧边栏导航 + 右侧内容区域 + * - 左侧 SidebarContent 显示 SettingsNavigate(设置子路由导航列表) + * - 右侧 AppContentScrollArea 通过 AnimatedOutletPreset 渲染子路由 + * - 使用 ref 相同的 Sidebar 布局模式(与 Proxies 一致) + */ + +import { cn } from '@chimera/ui'; +import { createFileRoute } from '@tanstack/react-router'; +import { AnimatedOutletPreset } from '@/components/router/animated-outlet'; +import { AppContentScrollArea } from '@/components/ui/scroll-area'; +import { Sidebar, SidebarContent } from '@/components/ui/sidebar'; +import SettingsNavigate from './_modules/settings-navigate'; + +export const Route = createFileRoute('/(main)/main/settings')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + + {/* 左侧侧边栏:设置子路由导航列表 */} + + + + + {/* + 右侧内容区域:设置子路由详情 + 使用 AppContentScrollArea 统一滚动管理 + 容器 max-w-7xl 匹配 ref 的最大宽度限制 + */} + +
+ +
+
+
+ ); +} diff --git a/frontend/chimera/src/routeTree.gen.ts b/frontend/chimera/src/routeTree.gen.ts index 95d81394..a7d923e5 100644 --- a/frontend/chimera/src/routeTree.gen.ts +++ b/frontend/chimera/src/routeTree.gen.ts @@ -21,8 +21,25 @@ import { Route as legacyLogsRouteImport } from './pages/(legacy)/logs' import { Route as legacyDashboardRouteImport } from './pages/(legacy)/dashboard' import { Route as legacyConnectionsRouteImport } from './pages/(legacy)/connections' import { Route as mainMainIndexRouteImport } from './pages/(main)/main/index' +import { Route as mainMainSettingsRouteRouteImport } from './pages/(main)/main/settings/route' +import { Route as mainMainRulesRouteRouteImport } from './pages/(main)/main/rules/route' +import { Route as mainMainProxiesRouteRouteImport } from './pages/(main)/main/proxies/route' import { Route as mainMainProvidersRouteRouteImport } from './pages/(main)/main/providers/route' +import { Route as mainMainProfilesRouteRouteImport } from './pages/(main)/main/profiles/route' +import { Route as mainMainLogsRouteRouteImport } from './pages/(main)/main/logs/route' +import { Route as mainMainDashboardRouteRouteImport } from './pages/(main)/main/dashboard/route' +import { Route as mainMainConnectionsRouteRouteImport } from './pages/(main)/main/connections/route' +import { Route as mainMainSettingsIndexRouteImport } from './pages/(main)/main/settings/index' +import { Route as mainMainRulesIndexRouteImport } from './pages/(main)/main/rules/index' +import { Route as mainMainProxiesIndexRouteImport } from './pages/(main)/main/proxies/index' import { Route as mainMainProvidersIndexRouteImport } from './pages/(main)/main/providers/index' +import { Route as mainMainProfilesIndexRouteImport } from './pages/(main)/main/profiles/index' +import { Route as mainMainLogsIndexRouteImport } from './pages/(main)/main/logs/index' +import { Route as mainMainDashboardIndexRouteImport } from './pages/(main)/main/dashboard/index' +import { Route as mainMainConnectionsIndexRouteImport } from './pages/(main)/main/connections/index' +import { Route as mainMainProxiesNameRouteImport } from './pages/(main)/main/proxies/$name' +import { Route as mainMainProfilesInspectRouteRouteImport } from './pages/(main)/main/profiles/inspect/route' +import { Route as mainMainProfilesTypeIndexRouteImport } from './pages/(main)/main/profiles/$type/index' const mainRouteRoute = mainRouteRouteImport.update({ id: '/(main)', @@ -82,16 +99,105 @@ const mainMainIndexRoute = mainMainIndexRouteImport.update({ path: '/main/', getParentRoute: () => mainRouteRoute, } as any) +const mainMainSettingsRouteRoute = mainMainSettingsRouteRouteImport.update({ + id: '/main/settings', + path: '/main/settings', + getParentRoute: () => mainRouteRoute, +} as any) +const mainMainRulesRouteRoute = mainMainRulesRouteRouteImport.update({ + id: '/main/rules', + path: '/main/rules', + getParentRoute: () => mainRouteRoute, +} as any) +const mainMainProxiesRouteRoute = mainMainProxiesRouteRouteImport.update({ + id: '/main/proxies', + path: '/main/proxies', + getParentRoute: () => mainRouteRoute, +} as any) const mainMainProvidersRouteRoute = mainMainProvidersRouteRouteImport.update({ id: '/main/providers', path: '/main/providers', getParentRoute: () => mainRouteRoute, } as any) +const mainMainProfilesRouteRoute = mainMainProfilesRouteRouteImport.update({ + id: '/main/profiles', + path: '/main/profiles', + getParentRoute: () => mainRouteRoute, +} as any) +const mainMainLogsRouteRoute = mainMainLogsRouteRouteImport.update({ + id: '/main/logs', + path: '/main/logs', + getParentRoute: () => mainRouteRoute, +} as any) +const mainMainDashboardRouteRoute = mainMainDashboardRouteRouteImport.update({ + id: '/main/dashboard', + path: '/main/dashboard', + getParentRoute: () => mainRouteRoute, +} as any) +const mainMainConnectionsRouteRoute = + mainMainConnectionsRouteRouteImport.update({ + id: '/main/connections', + path: '/main/connections', + getParentRoute: () => mainRouteRoute, + } as any) +const mainMainSettingsIndexRoute = mainMainSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainSettingsRouteRoute, +} as any) +const mainMainRulesIndexRoute = mainMainRulesIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainRulesRouteRoute, +} as any) +const mainMainProxiesIndexRoute = mainMainProxiesIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainProxiesRouteRoute, +} as any) const mainMainProvidersIndexRoute = mainMainProvidersIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => mainMainProvidersRouteRoute, } as any) +const mainMainProfilesIndexRoute = mainMainProfilesIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainProfilesRouteRoute, +} as any) +const mainMainLogsIndexRoute = mainMainLogsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainLogsRouteRoute, +} as any) +const mainMainDashboardIndexRoute = mainMainDashboardIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainDashboardRouteRoute, +} as any) +const mainMainConnectionsIndexRoute = + mainMainConnectionsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => mainMainConnectionsRouteRoute, + } as any) +const mainMainProxiesNameRoute = mainMainProxiesNameRouteImport.update({ + id: '/$name', + path: '/$name', + getParentRoute: () => mainMainProxiesRouteRoute, +} as any) +const mainMainProfilesInspectRouteRoute = + mainMainProfilesInspectRouteRouteImport.update({ + id: '/inspect', + path: '/inspect', + getParentRoute: () => mainMainProfilesRouteRoute, + } as any) +const mainMainProfilesTypeIndexRoute = + mainMainProfilesTypeIndexRouteImport.update({ + id: '/$type/', + path: '/$type/', + getParentRoute: () => mainMainProfilesRouteRoute, + } as any) export interface FileRoutesByFullPath { '/connections': typeof legacyConnectionsRoute @@ -103,9 +209,26 @@ export interface FileRoutesByFullPath { '/rules': typeof legacyRulesRoute '/settings': typeof legacySettingsRoute '/': typeof legacyIndexRoute + '/main/connections': typeof mainMainConnectionsRouteRouteWithChildren + '/main/dashboard': typeof mainMainDashboardRouteRouteWithChildren + '/main/logs': typeof mainMainLogsRouteRouteWithChildren + '/main/profiles': typeof mainMainProfilesRouteRouteWithChildren '/main/providers': typeof mainMainProvidersRouteRouteWithChildren + '/main/proxies': typeof mainMainProxiesRouteRouteWithChildren + '/main/rules': typeof mainMainRulesRouteRouteWithChildren + '/main/settings': typeof mainMainSettingsRouteRouteWithChildren '/main/': typeof mainMainIndexRoute + '/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute + '/main/proxies/$name': typeof mainMainProxiesNameRoute + '/main/connections/': typeof mainMainConnectionsIndexRoute + '/main/dashboard/': typeof mainMainDashboardIndexRoute + '/main/logs/': typeof mainMainLogsIndexRoute + '/main/profiles/': typeof mainMainProfilesIndexRoute '/main/providers/': typeof mainMainProvidersIndexRoute + '/main/proxies/': typeof mainMainProxiesIndexRoute + '/main/rules/': typeof mainMainRulesIndexRoute + '/main/settings/': typeof mainMainSettingsIndexRoute + '/main/profiles/$type/': typeof mainMainProfilesTypeIndexRoute } export interface FileRoutesByTo { '/connections': typeof legacyConnectionsRoute @@ -118,7 +241,17 @@ export interface FileRoutesByTo { '/settings': typeof legacySettingsRoute '/': typeof legacyIndexRoute '/main': typeof mainMainIndexRoute + '/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute + '/main/proxies/$name': typeof mainMainProxiesNameRoute + '/main/connections': typeof mainMainConnectionsIndexRoute + '/main/dashboard': typeof mainMainDashboardIndexRoute + '/main/logs': typeof mainMainLogsIndexRoute + '/main/profiles': typeof mainMainProfilesIndexRoute '/main/providers': typeof mainMainProvidersIndexRoute + '/main/proxies': typeof mainMainProxiesIndexRoute + '/main/rules': typeof mainMainRulesIndexRoute + '/main/settings': typeof mainMainSettingsIndexRoute + '/main/profiles/$type': typeof mainMainProfilesTypeIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -133,9 +266,26 @@ export interface FileRoutesById { '/(legacy)/rules': typeof legacyRulesRoute '/(legacy)/settings': typeof legacySettingsRoute '/(legacy)/': typeof legacyIndexRoute + '/(main)/main/connections': typeof mainMainConnectionsRouteRouteWithChildren + '/(main)/main/dashboard': typeof mainMainDashboardRouteRouteWithChildren + '/(main)/main/logs': typeof mainMainLogsRouteRouteWithChildren + '/(main)/main/profiles': typeof mainMainProfilesRouteRouteWithChildren '/(main)/main/providers': typeof mainMainProvidersRouteRouteWithChildren + '/(main)/main/proxies': typeof mainMainProxiesRouteRouteWithChildren + '/(main)/main/rules': typeof mainMainRulesRouteRouteWithChildren + '/(main)/main/settings': typeof mainMainSettingsRouteRouteWithChildren '/(main)/main/': typeof mainMainIndexRoute + '/(main)/main/profiles/inspect': typeof mainMainProfilesInspectRouteRoute + '/(main)/main/proxies/$name': typeof mainMainProxiesNameRoute + '/(main)/main/connections/': typeof mainMainConnectionsIndexRoute + '/(main)/main/dashboard/': typeof mainMainDashboardIndexRoute + '/(main)/main/logs/': typeof mainMainLogsIndexRoute + '/(main)/main/profiles/': typeof mainMainProfilesIndexRoute '/(main)/main/providers/': typeof mainMainProvidersIndexRoute + '/(main)/main/proxies/': typeof mainMainProxiesIndexRoute + '/(main)/main/rules/': typeof mainMainRulesIndexRoute + '/(main)/main/settings/': typeof mainMainSettingsIndexRoute + '/(main)/main/profiles/$type/': typeof mainMainProfilesTypeIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -149,9 +299,26 @@ export interface FileRouteTypes { | '/rules' | '/settings' | '/' + | '/main/connections' + | '/main/dashboard' + | '/main/logs' + | '/main/profiles' | '/main/providers' + | '/main/proxies' + | '/main/rules' + | '/main/settings' | '/main/' + | '/main/profiles/inspect' + | '/main/proxies/$name' + | '/main/connections/' + | '/main/dashboard/' + | '/main/logs/' + | '/main/profiles/' | '/main/providers/' + | '/main/proxies/' + | '/main/rules/' + | '/main/settings/' + | '/main/profiles/$type/' fileRoutesByTo: FileRoutesByTo to: | '/connections' @@ -164,7 +331,17 @@ export interface FileRouteTypes { | '/settings' | '/' | '/main' + | '/main/profiles/inspect' + | '/main/proxies/$name' + | '/main/connections' + | '/main/dashboard' + | '/main/logs' + | '/main/profiles' | '/main/providers' + | '/main/proxies' + | '/main/rules' + | '/main/settings' + | '/main/profiles/$type' id: | '__root__' | '/(legacy)' @@ -178,9 +355,26 @@ export interface FileRouteTypes { | '/(legacy)/rules' | '/(legacy)/settings' | '/(legacy)/' + | '/(main)/main/connections' + | '/(main)/main/dashboard' + | '/(main)/main/logs' + | '/(main)/main/profiles' | '/(main)/main/providers' + | '/(main)/main/proxies' + | '/(main)/main/rules' + | '/(main)/main/settings' | '/(main)/main/' + | '/(main)/main/profiles/inspect' + | '/(main)/main/proxies/$name' + | '/(main)/main/connections/' + | '/(main)/main/dashboard/' + | '/(main)/main/logs/' + | '/(main)/main/profiles/' | '/(main)/main/providers/' + | '/(main)/main/proxies/' + | '/(main)/main/rules/' + | '/(main)/main/settings/' + | '/(main)/main/profiles/$type/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -274,6 +468,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof mainMainIndexRouteImport parentRoute: typeof mainRouteRoute } + '/(main)/main/settings': { + id: '/(main)/main/settings' + path: '/main/settings' + fullPath: '/main/settings' + preLoaderRoute: typeof mainMainSettingsRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/rules': { + id: '/(main)/main/rules' + path: '/main/rules' + fullPath: '/main/rules' + preLoaderRoute: typeof mainMainRulesRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/proxies': { + id: '/(main)/main/proxies' + path: '/main/proxies' + fullPath: '/main/proxies' + preLoaderRoute: typeof mainMainProxiesRouteRouteImport + parentRoute: typeof mainRouteRoute + } '/(main)/main/providers': { id: '/(main)/main/providers' path: '/main/providers' @@ -281,6 +496,55 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof mainMainProvidersRouteRouteImport parentRoute: typeof mainRouteRoute } + '/(main)/main/profiles': { + id: '/(main)/main/profiles' + path: '/main/profiles' + fullPath: '/main/profiles' + preLoaderRoute: typeof mainMainProfilesRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/logs': { + id: '/(main)/main/logs' + path: '/main/logs' + fullPath: '/main/logs' + preLoaderRoute: typeof mainMainLogsRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/dashboard': { + id: '/(main)/main/dashboard' + path: '/main/dashboard' + fullPath: '/main/dashboard' + preLoaderRoute: typeof mainMainDashboardRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/connections': { + id: '/(main)/main/connections' + path: '/main/connections' + fullPath: '/main/connections' + preLoaderRoute: typeof mainMainConnectionsRouteRouteImport + parentRoute: typeof mainRouteRoute + } + '/(main)/main/settings/': { + id: '/(main)/main/settings/' + path: '/' + fullPath: '/main/settings/' + preLoaderRoute: typeof mainMainSettingsIndexRouteImport + parentRoute: typeof mainMainSettingsRouteRoute + } + '/(main)/main/rules/': { + id: '/(main)/main/rules/' + path: '/' + fullPath: '/main/rules/' + preLoaderRoute: typeof mainMainRulesIndexRouteImport + parentRoute: typeof mainMainRulesRouteRoute + } + '/(main)/main/proxies/': { + id: '/(main)/main/proxies/' + path: '/' + fullPath: '/main/proxies/' + preLoaderRoute: typeof mainMainProxiesIndexRouteImport + parentRoute: typeof mainMainProxiesRouteRoute + } '/(main)/main/providers/': { id: '/(main)/main/providers/' path: '/' @@ -288,6 +552,55 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof mainMainProvidersIndexRouteImport parentRoute: typeof mainMainProvidersRouteRoute } + '/(main)/main/profiles/': { + id: '/(main)/main/profiles/' + path: '/' + fullPath: '/main/profiles/' + preLoaderRoute: typeof mainMainProfilesIndexRouteImport + parentRoute: typeof mainMainProfilesRouteRoute + } + '/(main)/main/logs/': { + id: '/(main)/main/logs/' + path: '/' + fullPath: '/main/logs/' + preLoaderRoute: typeof mainMainLogsIndexRouteImport + parentRoute: typeof mainMainLogsRouteRoute + } + '/(main)/main/dashboard/': { + id: '/(main)/main/dashboard/' + path: '/' + fullPath: '/main/dashboard/' + preLoaderRoute: typeof mainMainDashboardIndexRouteImport + parentRoute: typeof mainMainDashboardRouteRoute + } + '/(main)/main/connections/': { + id: '/(main)/main/connections/' + path: '/' + fullPath: '/main/connections/' + preLoaderRoute: typeof mainMainConnectionsIndexRouteImport + parentRoute: typeof mainMainConnectionsRouteRoute + } + '/(main)/main/proxies/$name': { + id: '/(main)/main/proxies/$name' + path: '/$name' + fullPath: '/main/proxies/$name' + preLoaderRoute: typeof mainMainProxiesNameRouteImport + parentRoute: typeof mainMainProxiesRouteRoute + } + '/(main)/main/profiles/inspect': { + id: '/(main)/main/profiles/inspect' + path: '/inspect' + fullPath: '/main/profiles/inspect' + preLoaderRoute: typeof mainMainProfilesInspectRouteRouteImport + parentRoute: typeof mainMainProfilesRouteRoute + } + '/(main)/main/profiles/$type/': { + id: '/(main)/main/profiles/$type/' + path: '/$type' + fullPath: '/main/profiles/$type/' + preLoaderRoute: typeof mainMainProfilesTypeIndexRouteImport + parentRoute: typeof mainMainProfilesRouteRoute + } } } @@ -319,6 +632,62 @@ const legacyRouteRouteWithChildren = legacyRouteRoute._addFileChildren( legacyRouteRouteChildren, ) +interface mainMainConnectionsRouteRouteChildren { + mainMainConnectionsIndexRoute: typeof mainMainConnectionsIndexRoute +} + +const mainMainConnectionsRouteRouteChildren: mainMainConnectionsRouteRouteChildren = + { + mainMainConnectionsIndexRoute: mainMainConnectionsIndexRoute, + } + +const mainMainConnectionsRouteRouteWithChildren = + mainMainConnectionsRouteRoute._addFileChildren( + mainMainConnectionsRouteRouteChildren, + ) + +interface mainMainDashboardRouteRouteChildren { + mainMainDashboardIndexRoute: typeof mainMainDashboardIndexRoute +} + +const mainMainDashboardRouteRouteChildren: mainMainDashboardRouteRouteChildren = + { + mainMainDashboardIndexRoute: mainMainDashboardIndexRoute, + } + +const mainMainDashboardRouteRouteWithChildren = + mainMainDashboardRouteRoute._addFileChildren( + mainMainDashboardRouteRouteChildren, + ) + +interface mainMainLogsRouteRouteChildren { + mainMainLogsIndexRoute: typeof mainMainLogsIndexRoute +} + +const mainMainLogsRouteRouteChildren: mainMainLogsRouteRouteChildren = { + mainMainLogsIndexRoute: mainMainLogsIndexRoute, +} + +const mainMainLogsRouteRouteWithChildren = + mainMainLogsRouteRoute._addFileChildren(mainMainLogsRouteRouteChildren) + +interface mainMainProfilesRouteRouteChildren { + mainMainProfilesInspectRouteRoute: typeof mainMainProfilesInspectRouteRoute + mainMainProfilesIndexRoute: typeof mainMainProfilesIndexRoute + mainMainProfilesTypeIndexRoute: typeof mainMainProfilesTypeIndexRoute +} + +const mainMainProfilesRouteRouteChildren: mainMainProfilesRouteRouteChildren = { + mainMainProfilesInspectRouteRoute: mainMainProfilesInspectRouteRoute, + mainMainProfilesIndexRoute: mainMainProfilesIndexRoute, + mainMainProfilesTypeIndexRoute: mainMainProfilesTypeIndexRoute, +} + +const mainMainProfilesRouteRouteWithChildren = + mainMainProfilesRouteRoute._addFileChildren( + mainMainProfilesRouteRouteChildren, + ) + interface mainMainProvidersRouteRouteChildren { mainMainProvidersIndexRoute: typeof mainMainProvidersIndexRoute } @@ -333,13 +702,64 @@ const mainMainProvidersRouteRouteWithChildren = mainMainProvidersRouteRouteChildren, ) +interface mainMainProxiesRouteRouteChildren { + mainMainProxiesNameRoute: typeof mainMainProxiesNameRoute + mainMainProxiesIndexRoute: typeof mainMainProxiesIndexRoute +} + +const mainMainProxiesRouteRouteChildren: mainMainProxiesRouteRouteChildren = { + mainMainProxiesNameRoute: mainMainProxiesNameRoute, + mainMainProxiesIndexRoute: mainMainProxiesIndexRoute, +} + +const mainMainProxiesRouteRouteWithChildren = + mainMainProxiesRouteRoute._addFileChildren(mainMainProxiesRouteRouteChildren) + +interface mainMainRulesRouteRouteChildren { + mainMainRulesIndexRoute: typeof mainMainRulesIndexRoute +} + +const mainMainRulesRouteRouteChildren: mainMainRulesRouteRouteChildren = { + mainMainRulesIndexRoute: mainMainRulesIndexRoute, +} + +const mainMainRulesRouteRouteWithChildren = + mainMainRulesRouteRoute._addFileChildren(mainMainRulesRouteRouteChildren) + +interface mainMainSettingsRouteRouteChildren { + mainMainSettingsIndexRoute: typeof mainMainSettingsIndexRoute +} + +const mainMainSettingsRouteRouteChildren: mainMainSettingsRouteRouteChildren = { + mainMainSettingsIndexRoute: mainMainSettingsIndexRoute, +} + +const mainMainSettingsRouteRouteWithChildren = + mainMainSettingsRouteRoute._addFileChildren( + mainMainSettingsRouteRouteChildren, + ) + interface mainRouteRouteChildren { + mainMainConnectionsRouteRoute: typeof mainMainConnectionsRouteRouteWithChildren + mainMainDashboardRouteRoute: typeof mainMainDashboardRouteRouteWithChildren + mainMainLogsRouteRoute: typeof mainMainLogsRouteRouteWithChildren + mainMainProfilesRouteRoute: typeof mainMainProfilesRouteRouteWithChildren mainMainProvidersRouteRoute: typeof mainMainProvidersRouteRouteWithChildren + mainMainProxiesRouteRoute: typeof mainMainProxiesRouteRouteWithChildren + mainMainRulesRouteRoute: typeof mainMainRulesRouteRouteWithChildren + mainMainSettingsRouteRoute: typeof mainMainSettingsRouteRouteWithChildren mainMainIndexRoute: typeof mainMainIndexRoute } const mainRouteRouteChildren: mainRouteRouteChildren = { + mainMainConnectionsRouteRoute: mainMainConnectionsRouteRouteWithChildren, + mainMainDashboardRouteRoute: mainMainDashboardRouteRouteWithChildren, + mainMainLogsRouteRoute: mainMainLogsRouteRouteWithChildren, + mainMainProfilesRouteRoute: mainMainProfilesRouteRouteWithChildren, mainMainProvidersRouteRoute: mainMainProvidersRouteRouteWithChildren, + mainMainProxiesRouteRoute: mainMainProxiesRouteRouteWithChildren, + mainMainRulesRouteRoute: mainMainRulesRouteRouteWithChildren, + mainMainSettingsRouteRoute: mainMainSettingsRouteRouteWithChildren, mainMainIndexRoute: mainMainIndexRoute, } diff --git a/frontend/chimera/src/utils/language.ts b/frontend/chimera/src/utils/language.ts index ad619fec..6060192f 100644 --- a/frontend/chimera/src/utils/language.ts +++ b/frontend/chimera/src/utils/language.ts @@ -1,8 +1,8 @@ export const languageOptions = { en: 'English', ru: 'Русский', - 'zh-CN': '简体中文', - 'zh-TW': '繁體中文', + 'zh-cn': '简体中文', + 'zh-tw': '繁體中文', }; export const languageQuirks: { @@ -23,12 +23,12 @@ export const languageQuirks: { minWidth: 240, }, }, - 'zh-CN': { + 'zh-cn': { drawer: { minWidth: 180, }, }, - 'zh-TW': { + 'zh-tw': { drawer: { minWidth: 180, }, diff --git a/frontend/chimera/tsconfig.json b/frontend/chimera/tsconfig.json index f6102418..6366411c 100644 --- a/frontend/chimera/tsconfig.json +++ b/frontend/chimera/tsconfig.json @@ -20,6 +20,8 @@ "@root/*": ["../../*"], "@/*": ["./src/*"], "~/*": ["./*"], + "@chimera/interface": ["../interface/src/index.ts"], + "@chimera/interface/*": ["../interface/src/*"], "@chimera/utils": ["../utils/src/index.ts"], "@chimera/utils/*": ["../utils/src/*"], "@interface/*": ["../interface/src/*"], @@ -34,9 +36,5 @@ { "path": "./tsconfig.node.json", }, - // to import types from interface correctly in the local env. - { - "path": "../interface", - }, ], } diff --git a/frontend/interface/package.json b/frontend/interface/package.json index 6a9d6da4..7bd2ca30 100644 --- a/frontend/interface/package.json +++ b/frontend/interface/package.json @@ -19,15 +19,16 @@ "dependencies": { "@tanstack/react-query": "5.90.16", "@tauri-apps/api": "2.11.0", - "react": "19.2.7", - "lodash-es": "4.17.21", - "ofetch": "1.5.1", "ahooks": "3.9.7", "dayjs": "1.11.21", + "lodash-es": "4.17.21", + "ofetch": "1.5.1", + "react": "19.2.7", "swr": "2.3.6" }, "devDependencies": { - "@types/react": "19.2.7", - "@types/lodash-es": "4.17.12" + "@emotion/react": "11.14.0", + "@types/lodash-es": "4.17.12", + "@types/react": "19.2.7" } } diff --git a/frontend/interface/src/ipc/use-clash-proxies.ts b/frontend/interface/src/ipc/use-clash-proxies.ts index ca681793..3f2f9fd4 100644 --- a/frontend/interface/src/ipc/use-clash-proxies.ts +++ b/frontend/interface/src/ipc/use-clash-proxies.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ClashDelayOptions } from '@/service'; +import { ClashDelayOptions } from '../service'; import { unwrapResult } from '../utils'; import { commands, diff --git a/frontend/interface/src/ipc/use-profile.ts b/frontend/interface/src/ipc/use-profile.ts index 735fab14..1c323e81 100644 --- a/frontend/interface/src/ipc/use-profile.ts +++ b/frontend/interface/src/ipc/use-profile.ts @@ -4,6 +4,7 @@ import { commands, Profile_Serialize, ProfileBuilder_Deserialize, + Profiles_Serialize, ProfilesBuilder_Deserialize, RemoteProfileOptionsBuilder, } from './bindings'; @@ -11,10 +12,14 @@ import { RROFILES_QUERY_KEY } from './consts'; type URLImportParams = Parameters; -type NormalizedProfile = NonNullable< +export type NormalizedProfile = NonNullable< Profile_Serialize['remote'] | Profile_Serialize['local'] >; +export type NormalizedProfileBuilder = NonNullable< + ProfileBuilder_Deserialize['remote'] | ProfileBuilder_Deserialize['local'] +>; + type CreateParams = | { type: 'url'; @@ -26,7 +31,7 @@ type CreateParams = | { type: 'manual'; data: { - item: NormalizedProfile; + item: NormalizedProfileBuilder; fileData: string | null; }; }; @@ -40,6 +45,10 @@ type ProfileHelperFn = { export type ProfileQueryResultItem = NormalizedProfile & Partial; +export type ProfilesQueryResult = Omit & { + items: ProfileQueryResultItem[]; +}; + /** * A custom hook for managing profiles with various operations including creation, updating, sorting, and deletion. * @@ -82,6 +91,20 @@ export type ProfileQueryResultItem = NormalizedProfile & export const useProfile = (options?: { without_helper_fn?: boolean }) => { const queryClient = useQueryClient(); + const normalizeProfile = (item: Profile_Serialize): NormalizedProfile => { + return (item.remote ?? item.local) as NormalizedProfile; + }; + + const serializeProfileBuilder = ( + item: NormalizedProfileBuilder, + ): ProfileBuilder_Deserialize => { + if (item.type === 'remote') { + return { remote: item } as ProfileBuilder_Deserialize; + } + + return { local: item } as ProfileBuilder_Deserialize; + }; + /** * Mutation hook for creating or importing profiles * @@ -111,10 +134,7 @@ export const useProfile = (options?: { without_helper_fn?: boolean }) => { } else { const { item, fileData } = data; return unwrapResult( - await commands.createProfile( - item as unknown as ProfileBuilder_Deserialize, - fileData, - ), + await commands.createProfile(serializeProfileBuilder(item), fileData), ); } }, @@ -139,17 +159,26 @@ export const useProfile = (options?: { without_helper_fn?: boolean }) => { */ const query = useQuery({ queryKey: [RROFILES_QUERY_KEY], - queryFn: async () => { - const result = unwrapResult(await commands.getProfiles()); + queryFn: async (): Promise => { + const result = unwrapResult(await commands.getProfiles()) ?? { + current: [], + items: [], + valid: [], + chain: [], + }; + const items = result.items.map((item) => normalizeProfile(item)); // Skip helper functions if without_helper_fn is set if (options?.without_helper_fn) { - return result; + return { + ...result, + items, + }; } return { ...result, - items: result?.items?.map((item) => addHelperFn(item)), + items: result.items.map((item) => addHelperFn(item)), }; }, }); @@ -157,7 +186,7 @@ export const useProfile = (options?: { without_helper_fn?: boolean }) => { function addHelperFn( item: Profile_Serialize, ): NormalizedProfile & ProfileHelperFn { - const normalized = item as unknown as NormalizedProfile; + const normalized = normalizeProfile(item); const uid = normalized.uid; return { ...normalized, @@ -227,9 +256,11 @@ export const useProfile = (options?: { without_helper_fn?: boolean }) => { profile, }: { uid: string; - profile: ProfileBuilder_Deserialize; + profile: NormalizedProfileBuilder; }) => { - return unwrapResult(await commands.patchProfile(uid, profile)); + return unwrapResult( + await commands.patchProfile(uid, serializeProfileBuilder(profile)), + ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [RROFILES_QUERY_KEY] }); diff --git a/frontend/ui/src/materialYou/components/baseDialog/index.tsx b/frontend/ui/src/materialYou/components/baseDialog/index.tsx index 18722cac..68f00da8 100644 --- a/frontend/ui/src/materialYou/components/baseDialog/index.tsx +++ b/frontend/ui/src/materialYou/components/baseDialog/index.tsx @@ -10,7 +10,6 @@ import { useLayoutEffect, useState, } from 'react'; -import { useTranslation } from 'react-i18next'; import { getSystem, useClickPosition } from '../../../hooks'; import { alpha, cn } from '../../../utils'; @@ -45,8 +44,6 @@ export const BaseDialog = ({ ok, divider, }: BaseDialogProps) => { - const { t } = useTranslation(); - const { mode } = useColorScheme(); const [mounted, setMounted] = useState(false); @@ -226,7 +223,7 @@ export const BaseDialog = ({ variant="outlined" onClick={handleClose} > - {close || t('Close')} + {close || 'Close'} )} @@ -237,7 +234,7 @@ export const BaseDialog = ({ variant="contained" onClick={handleOk} > - {ok || t('Ok')} + {ok || 'OK'} )}
diff --git a/manifest/version.json b/manifest/version.json index e0a5cbc4..8e735fc2 100644 --- a/manifest/version.json +++ b/manifest/version.json @@ -2,9 +2,9 @@ "manifest_version": 1, "latest": { "mihomo": "v1.19.27", - "mihomo_alpha": "alpha-2cd1443", + "mihomo_alpha": "alpha-8e2aba4", "clash_rs": "v0.10.6", - "chimera_client": "v0.21.1", + "chimera_client": "v0.22.0", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.10.6-alpha+sha.73ba858" }, @@ -82,5 +82,5 @@ "linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2026-06-13T23:20:25.345Z" + "updated_at": "2026-06-17T23:49:52.776Z" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac0d51a..339291d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,12 +96,21 @@ importers: '@chimera/ui': specifier: workspace:^ version: link:../ui + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/helpers': + specifier: 0.5.0 + version: 0.5.0 + '@dnd-kit/react': + specifier: 0.5.0 + version: 0.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@emotion/styled': specifier: 11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.7))(@types/react@19.2.7)(react@19.2.7) '@inlang/paraglide-js': - specifier: 2.18.2 - version: 2.18.2(babel-plugin-macros@3.1.0)(typescript@5.9.3) + specifier: 2.20.0 + version: 2.20.0(babel-plugin-macros@3.1.0)(typescript@5.9.3) '@material/material-color-utilities': specifier: 0.4.0 version: 0.4.0 @@ -120,6 +129,12 @@ importers: '@tailwindcss/postcss': specifier: 4.1.16 version: 4.1.16 + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.2 + version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tauri-apps/api': specifier: 2.11.0 version: 2.11.0 @@ -156,6 +171,9 @@ importers: class-variance-authority: specifier: 0.7.1 version: 0.7.1 + d3: + specifier: ^7.9.0 + version: 7.9.0 dayjs: specifier: 1.11.21 version: 1.11.21 @@ -207,6 +225,9 @@ importers: swr: specifier: 2.3.6 version: 2.3.6(react@19.2.7) + vaul: + specifier: 1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) virtua: specifier: 0.45.3 version: 0.45.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -247,6 +268,9 @@ importers: '@tauri-apps/plugin-clipboard-manager': specifier: 2.3.2 version: 2.3.2 + '@types/d3': + specifier: 7.4.3 + version: 7.4.3 '@types/json-schema': specifier: 7.0.15 version: 7.0.15 @@ -341,6 +365,9 @@ importers: specifier: 2.3.6 version: 2.3.6(react@19.2.7) devDependencies: + '@emotion/react': + specifier: 11.14.0 + version: 11.14.0(@types/react@19.2.7)(react@19.2.7) '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 @@ -780,6 +807,46 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@dnd-kit/abstract@0.5.0': + resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/collision@0.5.0': + resolution: {integrity: sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==} + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/dom@0.5.0': + resolution: {integrity: sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==} + + '@dnd-kit/geometry@0.5.0': + resolution: {integrity: sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==} + + '@dnd-kit/helpers@0.5.0': + resolution: {integrity: sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg==} + + '@dnd-kit/react@0.5.0': + resolution: {integrity: sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.0': + resolution: {integrity: sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==} + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} @@ -1064,8 +1131,8 @@ packages: '@iconify/utils@3.1.1': resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} - '@inlang/paraglide-js@2.18.2': - resolution: {integrity: sha512-H2ksOE2dy9M4iJ+oDu8VPgk+B52C+OCFQpuJHBol0ZYLs3H17PCewVWIppkJNC36ClmLZnGhF+URjbcglTM8XQ==} + '@inlang/paraglide-js@2.20.0': + resolution: {integrity: sha512-vI8PdPVZSnpYnpagjvm+nWSa3nMDRJKVM/2eLAtUHFrNcZZadxVdmP79r4W95+dkMLDFGEyzSSY9sFxvjdkkyQ==} hasBin: true peerDependencies: typescript: '>=5.6' @@ -1076,8 +1143,8 @@ packages: '@inlang/recommend-sherlock@0.2.1': resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} - '@inlang/sdk@2.9.3': - resolution: {integrity: sha512-E/SxcSji8WIt4DqQG9APlOs6tVtJxrrOUS3dE4ho3pWRCLLIY0PIVzgNwSukuFT+m8LuJDFwpRY5VY3ryzyGWQ==} + '@inlang/sdk@2.10.2': + resolution: {integrity: sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==} engines: {node: '>=20.0.0'} '@isaacs/fs-minipass@4.0.1': @@ -1909,6 +1976,9 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@preact/signals-core@1.14.2': + resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==} + '@prettier/plugin-oxc@0.0.4': resolution: {integrity: sha512-UGXe+g/rSRbglL0FOJiar+a+nUrst7KaFmsg05wYbKiInGWP6eAj/f8A2Uobgo5KxEtb2X10zeflNH6RK2xeIQ==} engines: {node: '>=14'} @@ -2949,12 +3019,25 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.11.2': resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.2': + resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.171.13': resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==} engines: {node: '>=20.19'} @@ -3005,9 +3088,16 @@ packages: resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} engines: {node: '>=12'} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.11.2': resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==} + '@tanstack/virtual-core@3.17.0': + resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==} + '@tanstack/virtual-file-routes@1.162.0': resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} @@ -6622,6 +6712,12 @@ packages: varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -7266,6 +7362,68 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@dnd-kit/abstract@0.5.0': + dependencies: + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/collision@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/dom@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/collision': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.5.0': + dependencies: + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/helpers@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/react@0.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/dom': 0.5.0 + '@dnd-kit/state': 0.5.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/state@0.5.0': + dependencies: + '@preact/signals-core': 1.14.2 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + '@dual-bundle/import-meta-resolve@4.2.1': {} '@electron/get@2.0.3': @@ -7359,7 +7517,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.7)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -7532,10 +7690,10 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@inlang/paraglide-js@2.18.2(babel-plugin-macros@3.1.0)(typescript@5.9.3)': + '@inlang/paraglide-js@2.20.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)': dependencies: '@inlang/recommend-sherlock': 0.2.1 - '@inlang/sdk': 2.9.3(babel-plugin-macros@3.1.0) + '@inlang/sdk': 2.10.2(babel-plugin-macros@3.1.0) commander: 11.1.0 consola: 3.4.0 json5: 2.2.3 @@ -7550,7 +7708,7 @@ snapshots: dependencies: comment-json: 4.6.2 - '@inlang/sdk@2.9.3(babel-plugin-macros@3.1.0)': + '@inlang/sdk@2.10.2(babel-plugin-macros@3.1.0)': dependencies: '@lix-js/sdk': 0.4.10(babel-plugin-macros@3.1.0) '@sinclair/typebox': 0.31.17 @@ -8253,6 +8411,8 @@ snapshots: '@popperjs/core@2.11.8': {} + '@preact/signals-core@1.14.2': {} + '@prettier/plugin-oxc@0.0.4': dependencies: oxc-parser: 0.74.0 @@ -9288,12 +9448,24 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-virtual@3.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.11.2 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/router-core@1.171.13': dependencies: '@tanstack/history': 1.162.0 @@ -9356,8 +9528,12 @@ snapshots: '@tanstack/table-core@8.20.5': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.11.2': {} + '@tanstack/virtual-core@3.17.0': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@taplo/core@0.2.0': {} @@ -13085,6 +13261,15 @@ snapshots: varint@6.0.0: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3