diff --git a/bun.lock b/bun.lock index abf4563..a9153b2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,9 @@ "": { "name": "cpa-gui", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", @@ -59,6 +62,14 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -277,6 +288,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], diff --git a/package.json b/package.json index 9a905ed..4a1c659 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "tauri": "tauri" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2.0.0", "lucide-react": "^1.24.0", "react": "^19.0.0", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 2fa359b..3d3b1ff 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -652,6 +652,7 @@ export const jaOverrides = { 'apiAccess.notice.deleted': '接続を削除しました', 'apiAccess.notice.enabled': '接続を有効にしました', 'apiAccess.notice.disabled': '接続を無効にしました', + 'apiAccess.notice.reordered': 'プロバイダーの順序を更新しました', 'apiAccess.title': 'API 接続', 'apiAccess.count': '接続 {count} 件', 'apiAccess.add': '追加', @@ -673,6 +674,7 @@ export const jaOverrides = { 'apiAccess.status.disabled': '無効', 'apiAccess.status.enabled': '有効', 'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ', + 'apiAccess.dragHandle': '「{remark}」をドラッグして並べ替えます。キーボードでは Space の後に矢印キーを使用します', 'apiAccess.health.action': 'ヘルスチェック', 'apiAccess.health.title': 'モデルヘルスチェック', 'apiAccess.health.description': '{provider}', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c656185..a25180f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -652,6 +652,7 @@ export const en: Record = { 'apiAccess.notice.deleted': 'Connection deleted', 'apiAccess.notice.enabled': 'Connection enabled', 'apiAccess.notice.disabled': 'Connection disabled', + 'apiAccess.notice.reordered': 'Provider order updated', 'apiAccess.title': 'API Access', 'apiAccess.count': '{count} connections', 'apiAccess.add': 'Add', @@ -673,6 +674,7 @@ export const en: Record = { 'apiAccess.status.disabled': 'Disabled', 'apiAccess.status.enabled': 'Enabled', 'apiAccess.toggleAria': '{remark} connection {action} switch', + 'apiAccess.dragHandle': 'Drag to reorder {remark}; with a keyboard, press Space then use the arrow keys', 'apiAccess.health.action': 'Health Check', 'apiAccess.health.title': 'Model Health Check', 'apiAccess.health.description': '{provider}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6e3c340..0b98b36 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -650,6 +650,7 @@ export const zhCN = { 'apiAccess.notice.deleted': '接入已删除', 'apiAccess.notice.enabled': '接入已启用', 'apiAccess.notice.disabled': '接入已停用', + 'apiAccess.notice.reordered': '供应商排序已更新', 'apiAccess.title': 'API 接入', 'apiAccess.count': '{count} 个接入', 'apiAccess.add': '新增', @@ -671,6 +672,7 @@ export const zhCN = { 'apiAccess.status.disabled': '已停用', 'apiAccess.status.enabled': '已启用', 'apiAccess.toggleAria': '{remark} 接入{action}开关', + 'apiAccess.dragHandle': '拖动“{remark}”调整排序;键盘可按空格后使用方向键', 'apiAccess.health.action': '健康检测', 'apiAccess.health.title': '模型健康检测', 'apiAccess.health.description': '{provider}', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 70f07b3..35b2b47 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,9 +1,35 @@ -import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type CSSProperties, + FormEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + sortableKeyboardCoordinates, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS as DndCss } from '@dnd-kit/utilities'; import { Check, Edit3, Filter, + GripVertical, LoaderCircle, Plus, RefreshCw, @@ -79,6 +105,57 @@ type ProviderRow = { remark: string; }; +const providerDragId = (row: Pick) => + `${row.section}:${row.index}`; + +function SortableProviderRow({ + row, + disabled, + dragLabel, + isDragOver, + children, +}: { + row: ProviderRow; + disabled: boolean; + dragLabel: string; + isDragOver: boolean; + children: ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: providerDragId(row), disabled }); + const style: CSSProperties = { + transform: DndCss.Transform.toString(transform), + transition, + }; + + return ( +
+ + {children} +
+ ); +} + type ProviderSaveResult = | { saved: true } | { saved: false; error: string; target: 'form' | 'models' }; @@ -610,7 +687,7 @@ const providerIdentityMatches = ( ); }; -type ProviderRecordIdentity = Pick< +export type ProviderRecordIdentity = Pick< ProviderRow, 'section' | 'index' | 'name' | 'apiKey' | 'baseUrl' >; @@ -640,6 +717,35 @@ export const resolveProviderRecordIndex = ( return primaryMatches.length === 1 ? primaryMatches[0].index : -1; }; +export const reorderProviderRecords = ( + records: Record[], + scopeRows: ProviderRecordIdentity[], + source: ProviderRecordIdentity, + target: ProviderRecordIdentity, +) => { + const sourceIndex = resolveProviderRecordIndex(records, source); + const targetIndex = resolveProviderRecordIndex(records, target); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return null; + + const scopedIndexes = scopeRows + .map((row) => resolveProviderRecordIndex(records, row)) + .filter((index, position, indexes) => index >= 0 && indexes.indexOf(index) === position) + .sort((left, right) => left - right); + const sourcePosition = scopedIndexes.indexOf(sourceIndex); + const targetPosition = scopedIndexes.indexOf(targetIndex); + if (sourcePosition < 0 || targetPosition < 0) return null; + + const reordered = scopedIndexes.map((index) => stripResponseFields(records[index])); + const [moved] = reordered.splice(sourcePosition, 1); + reordered.splice(targetPosition, 0, moved); + + const next = records.map(stripResponseFields); + scopedIndexes.forEach((recordIndex, position) => { + next[recordIndex] = reordered[position]; + }); + return next; +}; + export const providerRecordWithDisabledState = ( section: ProviderSection, record: Record, @@ -674,8 +780,13 @@ export function ApiAccessPage() { const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); const [healthDialogRow, setHealthDialogRow] = useState(null); + const [dragOverId, setDragOverId] = useState(null); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; + const dragSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); const loadProviders = useCallback(async () => { setLoading(true); @@ -978,6 +1089,34 @@ export function ApiAccessPage() { } }; + const reorderProviders = async (source: ProviderRow, target: ProviderRow) => { + if (source.section !== target.section || source.index === target.index) return; + setBusy(true); + setError(''); + setNotice(''); + try { + const latestConfig = await managementApi.get('/config'); + const latestRows = sectionRecordsFromConfig(latestConfig, source.section); + const nextRows = reorderProviderRecords(latestRows, rows, source, target); + if (!nextRows) throw new Error(t('apiAccess.error.stale')); + await managementApi.put(`/${source.section}`, nextRows); + setNotice(t('apiAccess.notice.reordered')); + await loadProviders(); + } catch (requestError) { + setError(requestErrorMessage(requestError)); + } finally { + setDragOverId(null); + setBusy(false); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + setDragOverId(null); + const source = rows.find((row) => providerDragId(row) === String(event.active.id)); + const target = rows.find((row) => providerDragId(row) === String(event.over?.id ?? '')); + if (source && target) void reorderProviders(source, target); + }; + const totalCount = Object.values(records).reduce((sum, items) => sum + items.length, 0); const countForDefinition = (definition: ProviderDefinition) => @@ -1046,9 +1185,27 @@ export function ApiAccessPage() { {filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')} ) : ( -
- {rows.map((row) => ( -
+ setNotice('')} + onDragOver={({ over }) => setDragOverId(over ? String(over.id) : null)} + onDragCancel={() => setDragOverId(null)} + onDragEnd={handleDragEnd} + > + +
+ {rows.map((row) => ( +
{row.remark || row.name} @@ -1095,9 +1252,11 @@ export function ApiAccessPage() {
-
- ))} -
+ + ))} + + + )} diff --git a/src/styles.css b/src/styles.css index fd0efef..80762cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3684,12 +3684,37 @@ textarea:focus-visible { .real-provider-row { display: grid; - grid-template-columns: minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content; + grid-template-columns: 32px minmax(280px, 1.6fr) minmax(120px, 0.5fr) max-content; align-items: start; gap: 16px; min-height: 88px; padding: 14px 0; border-bottom: 1px solid var(--theme-eeece6); + transition: background 120ms ease, box-shadow 120ms ease, opacity 120ms ease; +} + +.real-provider-row.dragging { + opacity: 0.45; +} + +.real-provider-row.drag-over { + background: var(--theme-f4f1ea); + box-shadow: inset 0 2px 0 var(--theme-2d2a26); +} + +.provider-drag-handle { + margin-top: -2px; + cursor: grab; + color: var(--theme-9b958c); + touch-action: none; +} + +.provider-drag-handle:hover { + color: var(--theme-2d2a26); +} + +.provider-drag-handle:active { + cursor: grabbing; } .provider-row-main, @@ -7535,7 +7560,7 @@ textarea:focus-visible { } .real-provider-row { - grid-template-columns: minmax(0, 1fr) max-content; + grid-template-columns: 32px minmax(0, 1fr) max-content; } .provider-row-meta { @@ -7655,7 +7680,7 @@ textarea:focus-visible { } .real-provider-row { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: 32px minmax(0, 1fr) auto; } .real-auth-file-row { @@ -7979,10 +8004,11 @@ textarea:focus-visible { } .real-provider-row { - grid-template-columns: 1fr; + grid-template-columns: 32px minmax(0, 1fr); } .provider-row-actions { + grid-column: 2; flex-wrap: wrap; justify-content: flex-start; } @@ -8070,7 +8096,7 @@ textarea:focus-visible { } .real-provider-row { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: 32px minmax(0, 1fr); } .real-auth-file-row { @@ -8078,7 +8104,7 @@ textarea:focus-visible { } .provider-row-actions { - grid-column: 1; + grid-column: 2; justify-content: flex-start; } diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 97e9927..358f5da 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -14,6 +14,7 @@ import { providerCategoryMatchesRecord, providerRecordWithDisabledState, providerSectionOrder, + reorderProviderRecords, resolveProviderRecordIndex, sectionRecordsFromConfig, stripResponseFields, @@ -295,6 +296,37 @@ describe('API 接入配置合并', () => { expect(index).toBe(1); }); + it('拖动筛选后的供应商时保留隐藏供应商所在槽位', () => { + const records = [ + { name: 'OpenAI A', 'auth-index': 'runtime-a' }, + { name: 'DeepSeek', 'base-url': 'https://api.deepseek.com' }, + { name: 'OpenAI B', 'auth-index': 'runtime-b' }, + ]; + const openAiA = { + section: 'openai-compatibility' as const, + index: 0, + name: 'OpenAI A', + apiKey: '', + baseUrl: '', + }; + const openAiB = { + section: 'openai-compatibility' as const, + index: 2, + name: 'OpenAI B', + apiKey: '', + baseUrl: '', + }; + + const reordered = reorderProviderRecords(records, [openAiA, openAiB], openAiB, openAiA); + + expect(reordered?.map((record) => record.name)).toEqual([ + 'OpenAI B', + 'DeepSeek', + 'OpenAI A', + ]); + expect(reordered?.[0]['auth-index']).toBeUndefined(); + }); + it('校验自定义请求头格式', () => { expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({ Authorization: 'Bearer abc:def',