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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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())?;
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
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)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
@@ -39,6 +42,7 @@ impl Provider {
|
||||
website_url,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,133 +40,85 @@ interface ProviderListProps {
|
||||
onProvidersUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
providers,
|
||||
currentProviderId,
|
||||
// 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,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenUsageModal,
|
||||
onUrlClick,
|
||||
appType,
|
||||
onNotify,
|
||||
onProvidersUpdated,
|
||||
t,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [usageModalProviderId, setUsageModalProviderId] = useState<string | null>(null);
|
||||
|
||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
const cfg = provider.settingsConfig;
|
||||
// Claude/Anthropic: 从 env 中读取
|
||||
if (cfg?.env?.ANTHROPIC_BASE_URL) {
|
||||
return cfg.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
// Codex: 从 TOML 配置中解析 base_url
|
||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||
// 支持单/双引号
|
||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
if (match && match[2]) return match[2];
|
||||
}
|
||||
return t("provider.notConfigured");
|
||||
} catch {
|
||||
return t("provider.configError");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlClick = async (url: string) => {
|
||||
try {
|
||||
await window.api.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error(t("console.openLinkFailed"), error);
|
||||
onNotify?.(
|
||||
`${t("console.openLinkFailed")}: ${String(error)}`,
|
||||
"error",
|
||||
4000,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 列表页不再提供 Claude 插件按钮,统一在“设置”中控制
|
||||
|
||||
// 处理用量配置保存
|
||||
const handleSaveUsageScript = async (providerId: string, script: UsageScript) => {
|
||||
try {
|
||||
const provider = providers[providerId];
|
||||
const updatedProvider = {
|
||||
...provider,
|
||||
meta: {
|
||||
...provider.meta,
|
||||
usage_script: script,
|
||||
},
|
||||
};
|
||||
await window.api.updateProvider(updatedProvider, appType);
|
||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
||||
// 重新加载供应商列表,触发 UsageFooter 的 useEffect
|
||||
if (onProvidersUpdated) {
|
||||
await onProvidersUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存用量配置失败:", error);
|
||||
onNotify?.("保存失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 对供应商列表进行排序
|
||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||
// 按添加时间排序
|
||||
// 没有时间戳的视为最早添加的(排在最前面)
|
||||
// 有时间戳的按时间升序排列
|
||||
const timeA = a.createdAt || 0;
|
||||
const timeB = b.createdAt || 0;
|
||||
|
||||
// 如果都没有时间戳,按名称排序
|
||||
if (timeA === 0 && timeB === 0) {
|
||||
const locale = i18n.language === "zh" ? "zh-CN" : "en-US";
|
||||
return a.name.localeCompare(b.name, locale);
|
||||
}
|
||||
|
||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||
if (timeA === 0) return -1;
|
||||
if (timeB === 0) return 1;
|
||||
|
||||
// 都有时间戳,按时间升序
|
||||
return timeA - timeB;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: provider.id,
|
||||
animateLayoutChanges: () => false, // Disable layout animations
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedProviders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("provider.noProviders")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("provider.noProvidersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProviders.map((provider) => {
|
||||
const isCurrent = provider.id === currentProviderId;
|
||||
const apiUrl = getApiUrl(provider);
|
||||
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
|
||||
key={provider.id}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
||||
// 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,
|
||||
@@ -166,7 +135,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleUrlClick(provider.websiteUrl!);
|
||||
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", {
|
||||
@@ -209,9 +178,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
{/* 新增:用量配置按钮 */}
|
||||
<button
|
||||
onClick={() => setUsageModalProviderId(provider.id)}
|
||||
onClick={() => onOpenUsageModal(provider.id)}
|
||||
className={buttonStyles.icon}
|
||||
title="配置用量查询"
|
||||
>
|
||||
@@ -234,16 +202,201 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用量信息 Footer */}
|
||||
<UsageFooter
|
||||
providerId={provider.id}
|
||||
appType={appType!}
|
||||
appType={appType}
|
||||
usageEnabled={provider.meta?.usage_script?.enabled || false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
providers,
|
||||
currentProviderId,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit,
|
||||
appType,
|
||||
onNotify,
|
||||
onProvidersUpdated,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
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)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
const cfg = provider.settingsConfig;
|
||||
// Claude/Anthropic: 从 env 中读取
|
||||
if (cfg?.env?.ANTHROPIC_BASE_URL) {
|
||||
return cfg.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
// Codex: 从 TOML 配置中解析 base_url
|
||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||
// 支持单/双引号
|
||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
if (match && match[2]) return match[2];
|
||||
}
|
||||
return t("provider.notConfigured");
|
||||
} catch {
|
||||
return t("provider.configError");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlClick = async (url: string) => {
|
||||
try {
|
||||
await window.api.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error(t("console.openLinkFailed"), error);
|
||||
onNotify?.(
|
||||
`${t("console.openLinkFailed")}: ${String(error)}`,
|
||||
"error",
|
||||
4000,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 列表页不再提供 Claude 插件按钮,统一在"设置"中控制
|
||||
|
||||
// 处理用量配置保存
|
||||
const handleSaveUsageScript = async (providerId: string, script: UsageScript) => {
|
||||
try {
|
||||
const provider = providers[providerId];
|
||||
const updatedProvider = {
|
||||
...provider,
|
||||
meta: {
|
||||
...provider.meta,
|
||||
usage_script: script,
|
||||
},
|
||||
};
|
||||
await window.api.updateProvider(updatedProvider, appType);
|
||||
onNotify?.("用量查询配置已保存", "success", 2000);
|
||||
// 重新加载供应商列表,触发 UsageFooter 的 useEffect
|
||||
if (onProvidersUpdated) {
|
||||
await onProvidersUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存用量配置失败:", error);
|
||||
onNotify?.("保存失败", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// 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");
|
||||
}
|
||||
}, [sortedProviders, appType, onProvidersUpdated, onNotify, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedProviders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("provider.noProviders")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t("provider.noProvidersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
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 (
|
||||
<SortableProviderItem
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={isCurrent}
|
||||
apiUrl={apiUrl}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onOpenUsageModal={setUsageModalProviderId}
|
||||
onUrlClick={handleUrlClick}
|
||||
appType={appType}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 用量配置模态框 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
"configError": "配置错误",
|
||||
"notConfigured": "未配置官网地址",
|
||||
"applyToClaudePlugin": "应用到 Claude 插件",
|
||||
"removeFromClaudePlugin": "从 Claude 插件移除"
|
||||
"removeFromClaudePlugin": "从 Claude 插件移除",
|
||||
"dragToReorder": "拖拽以重新排序",
|
||||
"sortUpdateFailed": "排序更新失败"
|
||||
},
|
||||
"notifications": {
|
||||
"providerSaved": "供应商配置已保存",
|
||||
|
||||
@@ -683,6 +683,23 @@ export const tauriAPI = {
|
||||
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 对象,兼容现有代码
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Provider {
|
||||
// 新增:供应商分类(用于差异化提示/能力开关)
|
||||
category?: ProviderCategory;
|
||||
createdAt?: number; // 添加时间戳(毫秒)
|
||||
sortIndex?: number; // 排序索引(用于自定义拖拽排序)
|
||||
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
||||
meta?: ProviderMeta;
|
||||
}
|
||||
|
||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -140,6 +140,14 @@ declare global {
|
||||
providerId: string,
|
||||
url: string,
|
||||
) => 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: {
|
||||
isMac: boolean;
|
||||
|
||||
Reference in New Issue
Block a user