feat(ui): add drag-and-drop sorting for provider list (#126)

* feat(ui): add drag-and-drop sorting for provider list

Implement drag-and-drop functionality to allow users to reorder providers with custom sort indices.

Features:
- Install @dnd-kit libraries for drag-and-drop support
- Add sortIndex field to Provider type (frontend & backend)
- Implement SortableProviderItem component with drag handle
- Add update_providers_sort_order Tauri command
- Sync tray menu order with provider list sorting
- Add i18n support for drag-related UI text

Technical details:
- Use @dnd-kit/core and @dnd-kit/sortable for smooth drag interactions
- Disable animations for immediate response after drop
- Update tray menu immediately after reordering
- Sort priority: sortIndex → createdAt → name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ui): remove unused transition variable in ProviderList

Remove unused 'transition' destructured variable from useSortable hook
to fix TypeScript error TS6133. The transition property is hardcoded
as 'none' in the style object to prevent conflicts with drag operations.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
ZyphrZero
2025-10-15 22:21:06 +08:00
committed by GitHub
parent 3b6048b1e8
commit 9eb991d087
11 changed files with 482 additions and 140 deletions

View File

@@ -32,7 +32,9 @@
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.2", "@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", "@tailwindcss/vite": "^4.1.13",
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-dialog": "^2.4.0",
@@ -46,6 +48,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^16.0.0", "react-i18next": "^16.0.0",
"smol-toml": "^1.4.2",
"tailwindcss": "^4.1.13" "tailwindcss": "^4.1.13"
} }
} }

61
pnpm-lock.yaml generated
View File

@@ -26,6 +26,15 @@ importers:
'@codemirror/view': '@codemirror/view':
specifier: ^6.38.2 specifier: ^6.38.2
version: 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': '@tailwindcss/vite':
specifier: ^4.1.13 specifier: ^4.1.13
version: 4.1.13(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) 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': '@codemirror/view@6.38.2':
resolution: {integrity: sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==} 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': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1011,6 +1042,9 @@ packages:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.2: typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -1259,6 +1293,31 @@ snapshots:
style-mod: 4.1.2 style-mod: 4.1.2
w3c-keyname: 2.2.8 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': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@@ -1878,6 +1937,8 @@ snapshots:
mkdirp: 3.0.1 mkdirp: 3.0.1
yallist: 5.0.0 yallist: 5.0.0
tslib@2.8.1: {}
typescript@5.9.2: {} typescript@5.9.2: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}

View File

@@ -1493,3 +1493,50 @@ pub async fn set_app_config_dir_override(
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
Ok(true) 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<AppType>,
app: Option<String>,
appType: Option<String>,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, String> {
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)
}

View File

@@ -49,7 +49,28 @@ fn create_tray_menu(
menu_builder = menu_builder.item(&claude_header); menu_builder = menu_builder.item(&claude_header);
if !claude_manager.providers.is_empty() { 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 is_current = claude_manager.current == *id;
let item = CheckMenuItem::with_id( let item = CheckMenuItem::with_id(
app, app,
@@ -84,7 +105,28 @@ fn create_tray_menu(
menu_builder = menu_builder.item(&codex_header); menu_builder = menu_builder.item(&codex_header);
if !codex_manager.providers.is_empty() { 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 is_current = codex_manager.current == *id;
let item = CheckMenuItem::with_id( let item = CheckMenuItem::with_id(
app, app,
@@ -460,6 +502,8 @@ pub fn run() {
// app_config_dir override via Store // app_config_dir override via Store
commands::get_app_config_dir_override, commands::get_app_config_dir_override,
commands::set_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 // theirs: config import/export and dialogs
import_export::export_config_to_file, import_export::export_config_to_file,
import_export::import_config_from_file, import_export::import_config_from_file,

View File

@@ -19,6 +19,9 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")] #[serde(rename = "createdAt")]
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>, pub meta: Option<ProviderMeta>,
@@ -39,6 +42,7 @@ impl Provider {
website_url, website_url,
category: None, category: None,
created_at: None, created_at: None,
sort_index: None,
meta: None, meta: None,
} }
} }

View File

@@ -2,10 +2,27 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "../types"; import { Provider, UsageScript } from "../types";
import { AppType } from "../lib/tauri-api"; import { AppType } from "../lib/tauri-api";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3 } from "lucide-react"; import { Play, Edit3, Trash2, CheckCircle2, Users, Check, BarChart3, GripVertical } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; import { buttonStyles, badgeStyles, cn } from "../lib/styles";
import UsageFooter from "./UsageFooter"; import UsageFooter from "./UsageFooter";
import UsageScriptModal from "./UsageScriptModal"; 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 { interface ProviderListProps {
@@ -23,6 +40,177 @@ interface ProviderListProps {
onProvidersUpdated?: () => Promise<void>; onProvidersUpdated?: () => Promise<void>;
} }
// 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<void>;
appType: AppType;
t: any;
}
const SortableProviderItem: React.FC<SortableProviderItemProps> = ({
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 (
<div
ref={setNodeRef}
style={style}
className={cn(
// Base card styles without transitions that conflict with dragging
"bg-white rounded-lg border p-4 dark:bg-gray-900",
// Different border colors based on state
isCurrent
? "border-blue-500 shadow-sm bg-blue-50 dark:border-blue-400 dark:bg-blue-400/10"
: "border-gray-200 dark:border-gray-700",
// Hover effects only when not dragging
!isDragging && !isCurrent && "hover:border-gray-300 hover:shadow-sm dark:hover:border-gray-600",
// Shadow during drag
isDragging && "shadow-lg",
// Only apply transition when not dragging to prevent conflicts
!isDragging && "transition-[border-color,box-shadow] duration-200"
)}
>
<div className="flex items-center justify-between">
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded mr-2 transition-colors"
title={t("provider.dragToReorder") || "拖拽以重新排序"}
>
<GripVertical size={20} className="text-gray-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name}
</h3>
<div
className={cn(
badgeStyles.success,
!isCurrent && "invisible",
)}
>
<CheckCircle2 size={12} />
{t("provider.currentlyUsing")}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
onUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={t("providerForm.visitWebsite", {
url: provider.websiteUrl,
})}
>
{provider.websiteUrl}
</button>
) : (
<span
className="text-gray-500 dark:text-gray-400"
title={apiUrl}
>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)}
>
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
<button
onClick={() => onOpenUsageModal(provider.id)}
className={buttonStyles.icon}
title="配置用量查询"
>
<BarChart3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={cn(
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
)}
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>
</div>
</div>
<UsageFooter
providerId={provider.id}
appType={appType}
usageEnabled={provider.meta?.usage_script?.enabled || false}
/>
</div>
);
}
const ProviderList: React.FC<ProviderListProps> = ({ const ProviderList: React.FC<ProviderListProps> = ({
providers, providers,
currentProviderId, currentProviderId,
@@ -36,6 +224,18 @@ const ProviderList: React.FC<ProviderListProps> = ({
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null); const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null);
// Drag and drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 提取API地址兼容不同供应商配置Claude env / Codex TOML // 提取API地址兼容不同供应商配置Claude env / Codex TOML
const getApiUrl = (provider: Provider): string => { const getApiUrl = (provider: Provider): string => {
try { try {
@@ -69,7 +269,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
} }
}; };
// 列表页不再提供 Claude 插件按钮,统一在设置中控制 // 列表页不再提供 Claude 插件按钮,统一在"设置"中控制
// 处理用量配置保存 // 处理用量配置保存
const handleSaveUsageScript = async (providerId: string, script: UsageScript) => { const handleSaveUsageScript = async (providerId: string, script: UsageScript) => {
@@ -94,27 +294,59 @@ const ProviderList: React.FC<ProviderListProps> = ({
} }
}; };
// 对供应商列表进行排序 // Sort providers
const sortedProviders = Object.values(providers).sort((a, b) => { const sortedProviders = React.useMemo(() => {
// 按添加时间排序 return Object.values(providers).sort((a, b) => {
// 没有时间戳的视为最早添加的(排在最前面) // Priority 1: sortIndex
// 有时间戳的按时间升序排列 if (a.sortIndex !== undefined && b.sortIndex !== undefined) {
const timeA = a.createdAt || 0; return a.sortIndex - b.sortIndex;
const timeB = b.createdAt || 0; }
if (a.sortIndex !== undefined) return -1;
if (b.sortIndex !== undefined) return 1;
// 如果都没有时间戳,按名称排序 // Priority 2: createdAt
if (timeA === 0 && timeB === 0) { const timeA = a.createdAt || 0;
const locale = i18n.language === "zh" ? "zh-CN" : "en-US"; const timeB = b.createdAt || 0;
return a.name.localeCompare(b.name, locale); 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");
} }
}, [sortedProviders, appType, onProvidersUpdated, onNotify, t]);
// 如果只有一个没有时间戳,没有时间戳的排在前面
if (timeA === 0) return -1;
if (timeB === 0) return 1;
// 都有时间戳,按时间升序
return timeA - timeB;
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -131,119 +363,40 @@ const ProviderList: React.FC<ProviderListProps> = ({
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <DndContext
{sortedProviders.map((provider) => { sensors={sensors}
const isCurrent = provider.id === currentProviderId; collisionDetection={closestCenter}
const apiUrl = getApiUrl(provider); onDragEnd={handleDragEnd}
autoScroll={true}
>
<SortableContext
items={sortedProviders.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sortedProviders.map((provider) => {
const isCurrent = provider.id === currentProviderId;
const apiUrl = getApiUrl(provider);
return ( return (
<div <SortableProviderItem
key={provider.id} key={provider.id}
className={cn( provider={provider}
isCurrent ? cardStyles.selected : cardStyles.interactive, isCurrent={isCurrent}
)} apiUrl={apiUrl}
> onSwitch={onSwitch}
<div className="flex items-center justify-between"> onEdit={onEdit}
<div className="flex-1"> onDelete={onDelete}
<div className="flex items-center gap-3 mb-2"> onOpenUsageModal={setUsageModalProviderId}
<h3 className="font-medium text-gray-900 dark:text-gray-100"> onUrlClick={handleUrlClick}
{provider.name} appType={appType}
</h3> t={t}
{/* 分类徽章已移除 */} />
<div );
className={cn( })}
badgeStyles.success, </div>
!isCurrent && "invisible", </SortableContext>
)} </DndContext>
>
<CheckCircle2 size={12} />
{t("provider.currentlyUsing")}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 text-blue-500 dark:text-blue-400 hover:opacity-90 transition-colors"
title={t("providerForm.visitWebsite", {
url: provider.websiteUrl,
})}
>
{provider.websiteUrl}
</button>
) : (
<span
className="text-gray-500 dark:text-gray-400"
title={apiUrl}
>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
isCurrent
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)}
>
{isCurrent ? <Check size={14} /> : <Play size={14} />}
{isCurrent ? t("provider.inUse") : t("provider.enable")}
</button>
<button
onClick={() => onEdit(provider.id)}
className={buttonStyles.icon}
title={t("provider.editProvider")}
>
<Edit3 size={16} />
</button>
{/* 新增:用量配置按钮 */}
<button
onClick={() => setUsageModalProviderId(provider.id)}
className={buttonStyles.icon}
title="配置用量查询"
>
<BarChart3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={cn(
buttonStyles.icon,
isCurrent
? "text-gray-400 cursor-not-allowed"
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10",
)}
title={t("provider.deleteProvider")}
>
<Trash2 size={16} />
</button>
</div>
</div>
{/* 用量信息 Footer */}
<UsageFooter
providerId={provider.id}
appType={appType!}
usageEnabled={provider.meta?.usage_script?.enabled || false}
/>
</div>
);
})}
</div>
)} )}
{/* 用量配置模态框 */} {/* 用量配置模态框 */}

View File

@@ -63,7 +63,9 @@
"configError": "Configuration Error", "configError": "Configuration Error",
"notConfigured": "Not configured for official website", "notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin", "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": { "notifications": {
"providerSaved": "Provider configuration saved", "providerSaved": "Provider configuration saved",

View File

@@ -63,7 +63,9 @@
"configError": "配置错误", "configError": "配置错误",
"notConfigured": "未配置官网地址", "notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件", "applyToClaudePlugin": "应用到 Claude 插件",
"removeFromClaudePlugin": "从 Claude 插件移除" "removeFromClaudePlugin": "从 Claude 插件移除",
"dragToReorder": "拖拽以重新排序",
"sortUpdateFailed": "排序更新失败"
}, },
"notifications": { "notifications": {
"providerSaved": "供应商配置已保存", "providerSaved": "供应商配置已保存",

View File

@@ -683,6 +683,23 @@ export const tauriAPI = {
throw error; throw error;
} }
}, },
// Update providers sort order
updateProvidersSortOrder: async (
updates: Array<{ id: string; sortIndex: number }>,
app?: AppType,
): Promise<boolean> => {
try {
return await invoke<boolean>("update_providers_sort_order", {
updates,
app_type: app,
app,
});
} catch (error) {
console.error("更新供应商排序失败:", error);
throw error;
}
},
}; };
// 创建全局 API 对象,兼容现有代码 // 创建全局 API 对象,兼容现有代码

View File

@@ -13,6 +13,7 @@ export interface Provider {
// 新增:供应商分类(用于差异化提示/能力开关) // 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory; category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒) createdAt?: number; // 添加时间戳(毫秒)
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置) // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta; meta?: ProviderMeta;
} }

8
src/vite-env.d.ts vendored
View File

@@ -140,6 +140,14 @@ declare global {
providerId: string, providerId: string,
url: string, url: string,
) => Promise<void>; ) => Promise<void>;
// Provider sort order management
updateProvidersSortOrder: (
updates: Array<{ id: string; sortIndex: number }>,
app?: AppType,
) => Promise<boolean>;
// app_config_dir override via Store
getAppConfigDirOverride: () => Promise<string | null>;
setAppConfigDirOverride: (path: string | null) => Promise<boolean>;
}; };
platform: { platform: {
isMac: boolean; isMac: boolean;