feat(api-access): add drag-and-drop provider reordering

This commit is contained in:
lzt404 committed 2026-08-14 17:42:42 +08:00
1 parent dbd97d862f
commit 813fbf97a8
8 files changed
+253 -14

No files matched your search

+13
View File
@@ -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=="],
+3
View File
@@ -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",
+2
View File
@@ -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}',
+2
View File
@@ -652,6 +652,7 @@ export const en: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'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}',
+2
View File
@@ -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}',
+167 -8
View File
@@ -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<ProviderRow, 'section' | 'index'>) =>
`${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 (
<article
ref={setNodeRef}
style={style}
className={`real-provider-row${isDragging ? ' dragging' : ''}${isDragOver && !isDragging ? ' drag-over' : ''}`}
>
<button
type="button"
className="icon-button quiet provider-drag-handle"
disabled={disabled}
aria-label={dragLabel}
title={dragLabel}
{...attributes}
{...listeners}
>
<GripVertical size={17} aria-hidden="true" />
</button>
{children}
</article>
);
}
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<string, unknown>[],
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<string, unknown>,
@@ -674,8 +780,13 @@ export function ApiAccessPage() {
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(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() {
<span>{filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')}</span>
</div>
) : (
<div className="real-provider-list">
{rows.map((row) => (
<article className="real-provider-row" key={`${row.section}-${row.index}-${row.authIndex}`}>
<DndContext
sensors={dragSensors}
collisionDetection={closestCenter}
onDragStart={() => setNotice('')}
onDragOver={({ over }) => setDragOverId(over ? String(over.id) : null)}
onDragCancel={() => setDragOverId(null)}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rows.map(providerDragId)}
strategy={verticalListSortingStrategy}
>
<div className="real-provider-list">
{rows.map((row) => (
<SortableProviderRow
key={`${row.section}-${row.index}-${row.authIndex}`}
row={row}
disabled={busy || rows.length < 2}
dragLabel={t('apiAccess.dragHandle', { remark: row.remark || row.name })}
isDragOver={dragOverId === providerDragId(row)}
>
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
@@ -1095,9 +1252,11 @@ export function ApiAccessPage() {
<Trash2 size={16} />
</button>
</div>
</article>
))}
</div>
</SortableProviderRow>
))}
</div>
</SortableContext>
</DndContext>
)}
</section>
</div>
+32 -6
View File
@@ -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;
}
+32
View File
@@ -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',