diff --git a/package.json b/package.json index 8fdf7fb..3f3b086 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.2", - "smol-toml": "^1.4.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.13", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", @@ -46,6 +48,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.0.0", + "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55138f8..f46cb7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,15 @@ importers: '@codemirror/view': specifier: ^6.38.2 version: 6.38.2 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) @@ -220,6 +229,28 @@ packages: '@codemirror/view@6.38.2': resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1011,6 +1042,9 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1259,6 +1293,31 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1878,6 +1937,8 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tslib@2.8.1: {} + typescript@5.9.2: {} undici-types@6.21.0: {} @@ -1904,4 +1965,4 @@ snapshots: yallist@3.1.1: {} - yallist@5.0.0: {} \ No newline at end of file + yallist@5.0.0: {} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9f1cbd6..734846d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1491,5 +1491,52 @@ pub async fn set_app_config_dir_override( path: Option, ) -> Result { crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; + Ok(true) +} + +// ===================== +// Provider Sort Order Management +// ===================== + +#[derive(serde::Deserialize)] +pub struct ProviderSortUpdate { + pub id: String, + #[serde(rename = "sortIndex")] + pub sort_index: usize, +} + +/// Update sort order for multiple providers +#[tauri::command] +pub async fn update_providers_sort_order( + state: State<'_, AppState>, + app_type: Option, + app: Option, + appType: Option, + updates: Vec, +) -> Result { + let app_type = app_type + .or_else(|| app.as_deref().map(|s| s.into())) + .or_else(|| appType.as_deref().map(|s| s.into())) + .unwrap_or(AppType::Claude); + + let mut config = state + .config + .lock() + .map_err(|e| format!("获取锁失败: {}", e))?; + + let manager = config + .get_manager_mut(&app_type) + .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; + + // Update sort_index for each provider + for update in updates { + if let Some(provider) = manager.providers.get_mut(&update.id) { + provider.sort_index = Some(update.sort_index); + } + } + + drop(config); + state.save()?; + Ok(true) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f08b5b7..1150b38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,7 +49,28 @@ fn create_tray_menu( menu_builder = menu_builder.item(&claude_header); if !claude_manager.providers.is_empty() { - for (id, provider) in &claude_manager.providers { + // Sort providers by sortIndex, then by createdAt, then by name + let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect(); + sorted_providers.sort_by(|(_, a), (_, b)| { + // Priority 1: sortIndex + match (a.sort_index, b.sort_index) { + (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + _ => {} + } + // Priority 2: createdAt + match (a.created_at, b.created_at) { + (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), + (Some(_), None) => return std::cmp::Ordering::Greater, + (None, Some(_)) => return std::cmp::Ordering::Less, + _ => {} + } + // Priority 3: name + a.name.cmp(&b.name) + }); + + for (id, provider) in sorted_providers { let is_current = claude_manager.current == *id; let item = CheckMenuItem::with_id( app, @@ -84,7 +105,28 @@ fn create_tray_menu( menu_builder = menu_builder.item(&codex_header); if !codex_manager.providers.is_empty() { - for (id, provider) in &codex_manager.providers { + // Sort providers by sortIndex, then by createdAt, then by name + let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect(); + sorted_providers.sort_by(|(_, a), (_, b)| { + // Priority 1: sortIndex + match (a.sort_index, b.sort_index) { + (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), + (Some(_), None) => return std::cmp::Ordering::Less, + (None, Some(_)) => return std::cmp::Ordering::Greater, + _ => {} + } + // Priority 2: createdAt + match (a.created_at, b.created_at) { + (Some(time_a), Some(time_b)) => return time_a.cmp(&time_b), + (Some(_), None) => return std::cmp::Ordering::Greater, + (None, Some(_)) => return std::cmp::Ordering::Less, + _ => {} + } + // Priority 3: name + a.name.cmp(&b.name) + }); + + for (id, provider) in sorted_providers { let is_current = codex_manager.current == *id; let item = CheckMenuItem::with_id( app, @@ -460,6 +502,8 @@ pub fn run() { // app_config_dir override via Store commands::get_app_config_dir_override, commands::set_app_config_dir_override, + // provider sort order management + commands::update_providers_sort_order, // theirs: config import/export and dialogs import_export::export_config_to_file, import_export::import_config_from_file, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index bda9127..08e9cb2 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -19,6 +19,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "createdAt")] pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sortIndex")] + pub sort_index: Option, /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -39,6 +42,7 @@ impl Provider { website_url, category: None, created_at: None, + sort_index: None, meta: None, } } diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index 46b9c23..71ea236 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -2,10 +2,27 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Provider, UsageScript } from "../types"; import { AppType } from "../lib/tauri-api"; -import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3 } from "lucide-react"; -import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; +import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3, GripVertical } from "lucide-react"; +import { buttonStyles, badgeStyles, cn } from "../lib/styles"; import UsageFooter from "./UsageFooter"; import UsageScriptModal from "./UsageScriptModal"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // 不再在列表中显示分类徽章,避免造成困惑 interface ProviderListProps { @@ -23,6 +40,177 @@ interface ProviderListProps { onProvidersUpdated?: () => Promise; } +// Sortable Provider Item Component +interface SortableProviderItemProps { + provider: Provider; + isCurrent: boolean; + apiUrl: string; + onSwitch: (id: string) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; + onOpenUsageModal: (id: string) => void; + onUrlClick: (url: string) => Promise; + appType: AppType; + t: any; +} + +const SortableProviderItem: React.FC = ({ + provider, + isCurrent, + apiUrl, + onSwitch, + onEdit, + onDelete, + onOpenUsageModal, + onUrlClick, + appType, + t, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + isDragging, + } = useSortable({ + id: provider.id, + animateLayoutChanges: () => false, // Disable layout animations + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: 'none', // No transitions at all + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : undefined, + }; + + return ( +
+
+ {/* Drag Handle */} +
+ +
+ +
+
+

+ {provider.name} +

+
+ + {t("provider.currentlyUsing")} +
+
+ +
+ {provider.websiteUrl ? ( + + ) : ( + + {apiUrl} + + )} +
+
+ +
+ + + + + + + +
+
+ + +
+ ); +} + const ProviderList: React.FC = ({ providers, currentProviderId, @@ -36,6 +224,18 @@ const ProviderList: React.FC = ({ const { t, i18n } = useTranslation(); const [usageModalProviderId, setUsageModalProviderId] = useState(null); + // Drag and drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + // 提取API地址(兼容不同供应商配置:Claude env / Codex TOML) const getApiUrl = (provider: Provider): string => { try { @@ -69,7 +269,7 @@ const ProviderList: React.FC = ({ } }; - // 列表页不再提供 Claude 插件按钮,统一在“设置”中控制 + // 列表页不再提供 Claude 插件按钮,统一在"设置"中控制 // 处理用量配置保存 const handleSaveUsageScript = async (providerId: string, script: UsageScript) => { @@ -94,27 +294,59 @@ const ProviderList: React.FC = ({ } }; - // 对供应商列表进行排序 - const sortedProviders = Object.values(providers).sort((a, b) => { - // 按添加时间排序 - // 没有时间戳的视为最早添加的(排在最前面) - // 有时间戳的按时间升序排列 - const timeA = a.createdAt || 0; - const timeB = b.createdAt || 0; + // Sort providers + const sortedProviders = React.useMemo(() => { + return Object.values(providers).sort((a, b) => { + // Priority 1: sortIndex + if (a.sortIndex !== undefined && b.sortIndex !== undefined) { + return a.sortIndex - b.sortIndex; + } + if (a.sortIndex !== undefined) return -1; + if (b.sortIndex !== undefined) return 1; - // 如果都没有时间戳,按名称排序 - if (timeA === 0 && timeB === 0) { - const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; - return a.name.localeCompare(b.name, locale); + // Priority 2: createdAt + const timeA = a.createdAt || 0; + const timeB = b.createdAt || 0; + if (timeA !== 0 && timeB !== 0) return timeA - timeB; + if (timeA === 0 && timeB === 0) { + // Priority 3: name + const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; + return a.name.localeCompare(b.name, locale); + } + return timeA === 0 ? -1 : 1; + }); + }, [providers, i18n.language]); + + // Handle drag end - immediate refresh + const handleDragEnd = React.useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + const oldIndex = sortedProviders.findIndex((p) => p.id === active.id); + const newIndex = sortedProviders.findIndex((p) => p.id === over.id); + + if (oldIndex === -1 || newIndex === -1) return; + + // Calculate new sort order + const reorderedProviders = arrayMove(sortedProviders, oldIndex, newIndex); + const updates = reorderedProviders.map((provider, index) => ({ + id: provider.id, + sortIndex: index, + })); + + try { + // Save to backend and refresh immediately + await window.api.updateProvidersSortOrder(updates, appType); + onProvidersUpdated?.(); + + // Update tray menu to reflect new order + await window.api.updateTrayMenu(); + } catch (error) { + console.error("Failed to update sort order:", error); + onNotify?.(t("provider.sortUpdateFailed") || "排序更新失败", "error"); } - - // 如果只有一个没有时间戳,没有时间戳的排在前面 - if (timeA === 0) return -1; - if (timeB === 0) return 1; - - // 都有时间戳,按时间升序 - return timeA - timeB; - }); + }, [sortedProviders, appType, onProvidersUpdated, onNotify, t]); return (
@@ -131,119 +363,40 @@ const ProviderList: React.FC = ({

) : ( -
- {sortedProviders.map((provider) => { - const isCurrent = provider.id === currentProviderId; - const apiUrl = getApiUrl(provider); + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {sortedProviders.map((provider) => { + const isCurrent = provider.id === currentProviderId; + const apiUrl = getApiUrl(provider); - return ( -
-
-
-
-

- {provider.name} -

- {/* 分类徽章已移除 */} -
- - {t("provider.currentlyUsing")} -
-
- -
- {provider.websiteUrl ? ( - - ) : ( - - {apiUrl} - - )} -
-
- -
- - - - - {/* 新增:用量配置按钮 */} - - - -
-
- - {/* 用量信息 Footer */} - -
- ); - })} -
+ return ( + + ); + })} +
+ + )} {/* 用量配置模态框 */} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2263b4..bb7acec 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -63,7 +63,9 @@ "configError": "Configuration Error", "notConfigured": "Not configured for official website", "applyToClaudePlugin": "Apply to Claude plugin", - "removeFromClaudePlugin": "Remove from Claude plugin" + "removeFromClaudePlugin": "Remove from Claude plugin", + "dragToReorder": "Drag to reorder", + "sortUpdateFailed": "Failed to update sort order" }, "notifications": { "providerSaved": "Provider configuration saved", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 28a80d2..8ff2b19 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -63,7 +63,9 @@ "configError": "配置错误", "notConfigured": "未配置官网地址", "applyToClaudePlugin": "应用到 Claude 插件", - "removeFromClaudePlugin": "从 Claude 插件移除" + "removeFromClaudePlugin": "从 Claude 插件移除", + "dragToReorder": "拖拽以重新排序", + "sortUpdateFailed": "排序更新失败" }, "notifications": { "providerSaved": "供应商配置已保存", diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 973b2c2..219c053 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -683,6 +683,23 @@ export const tauriAPI = { throw error; } }, + + // Update providers sort order + updateProvidersSortOrder: async ( + updates: Array<{ id: string; sortIndex: number }>, + app?: AppType, + ): Promise => { + try { + return await invoke("update_providers_sort_order", { + updates, + app_type: app, + app, + }); + } catch (error) { + console.error("更新供应商排序失败:", error); + throw error; + } + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/types.ts b/src/types.ts index ee0150d..ae4c20b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface Provider { // 新增:供应商分类(用于差异化提示/能力开关) category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) + sortIndex?: number; // 排序索引(用于自定义拖拽排序) // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) meta?: ProviderMeta; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8587079..d202b01 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -140,6 +140,14 @@ declare global { providerId: string, url: string, ) => Promise; + // Provider sort order management + updateProvidersSortOrder: ( + updates: Array<{ id: string; sortIndex: number }>, + app?: AppType, + ) => Promise; + // app_config_dir override via Store + getAppConfigDirOverride: () => Promise; + setAppConfigDirOverride: (path: string | null) => Promise; }; platform: { isMac: boolean;