feat(providers): support drag-and-drop reordering of the providers list

为服务商列表添加拖拽排序(#753)。复用项目已有的原生 HTML5 拖拽模式(对齐
Sidebar),零新增依赖;通过重排 providers.json 的数组顺序持久化,不引入 order
字段;仅行首手柄可拖,避免与行内操作按钮冲突;采用乐观更新 + 失败回滚。

后端: reorderProviders service、PUT /api/providers/reorder 路由、
ReorderProvidersSchema(校验 orderedIds 为现有 id 的排列)。
前端: providersApi.reorder、store action、Settings 拖拽手柄 UI、中/英/日/韩/繁文案。
测试: 后端 9 用例 + 前端 3 用例。
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 16:21:58 +08:00
parent a932ebe017
commit 8c99b0f892
13 changed files with 338 additions and 1 deletions

View File

@ -61,6 +61,10 @@ export const providersApi = {
return api.post<{ ok: true }>('/api/providers/official')
},
reorder(orderedIds: string[]) {
return api.put<{ providers: SavedProvider[] }>('/api/providers/reorder', { orderedIds })
},
test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) {
return api.post<TestResultResponse>(`/api/providers/${id}/test`, overrides)
},

View File

@ -381,6 +381,7 @@ export const en = {
'settings.providers.setDefault': 'Set default',
'settings.providers.test': 'Test',
'settings.providers.edit': 'Edit',
'settings.providers.dragToReorder': 'Drag to reorder',
'settings.providers.requestFailed': 'Request failed',
'settings.providers.addTitle': 'Add Provider',
'settings.providers.editTitle': 'Edit Provider',

View File

@ -383,6 +383,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.setDefault': 'デフォルトに設定',
'settings.providers.test': 'テスト',
'settings.providers.edit': '編集',
'settings.providers.dragToReorder': 'ドラッグして並べ替え',
'settings.providers.requestFailed': 'リクエストに失敗しました',
'settings.providers.addTitle': 'プロバイダーを追加',
'settings.providers.editTitle': 'プロバイダーを編集',

View File

@ -383,6 +383,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.setDefault': '기본값으로 설정',
'settings.providers.test': '테스트',
'settings.providers.edit': '편집',
'settings.providers.dragToReorder': '드래그하여 순서 변경',
'settings.providers.requestFailed': '요청에 실패했습니다',
'settings.providers.addTitle': '공급자 추가',
'settings.providers.editTitle': '공급자 편집',

View File

@ -383,6 +383,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.setDefault': '設為預設',
'settings.providers.test': '測試',
'settings.providers.edit': '編輯',
'settings.providers.dragToReorder': '拖曳排序',
'settings.providers.requestFailed': '請求失敗',
'settings.providers.addTitle': '新增服務商',
'settings.providers.editTitle': '編輯服務商',

View File

@ -383,6 +383,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.setDefault': '设为默认',
'settings.providers.test': '测试',
'settings.providers.edit': '编辑',
'settings.providers.dragToReorder': '拖动排序',
'settings.providers.requestFailed': '请求失败',
'settings.providers.addTitle': '添加服务商',
'settings.providers.editTitle': '编辑服务商',

View File

@ -229,6 +229,7 @@ function ProviderSettings() {
fetchProviders,
fetchPresets,
deleteProvider,
reorderProviders,
activateProvider,
activateOfficial,
testProvider,
@ -240,6 +241,8 @@ function ProviderSettings() {
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
useEffect(() => {
void fetchProviders()
@ -289,6 +292,46 @@ function ProviderSettings() {
await fetchSettings()
}
const handleProviderDragStart = (event: React.DragEvent, id: string) => {
setDraggingId(id)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', id)
}
const handleProviderDragOver = (event: React.DragEvent, overId: string) => {
if (!draggingId || draggingId === overId) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
if (dragOverId !== overId) setDragOverId(overId)
}
const clearProviderDragState = () => {
setDraggingId(null)
setDragOverId(null)
}
const handleProviderDrop = (event: React.DragEvent, targetId: string) => {
event.preventDefault()
const sourceId = draggingId || event.dataTransfer.getData('text/plain')
if (!sourceId || sourceId === targetId) {
clearProviderDragState()
return
}
const ids = providers.map((p) => p.id)
const from = ids.indexOf(sourceId)
const to = ids.indexOf(targetId)
if (from === -1 || to === -1) {
clearProviderDragState()
return
}
ids.splice(from, 1)
ids.splice(to, 0, sourceId)
clearProviderDragState()
void reorderProviders(ids)
}
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
@ -382,12 +425,28 @@ function ProviderSettings() {
return (
<div
key={provider.id}
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
onDragOver={(e) => handleProviderDragOver(e, provider.id)}
onDrop={(e) => handleProviderDrop(e, provider.id)}
onDragLeave={() => setDragOverId((cur) => (cur === provider.id ? null : cur))}
className={`relative flex items-center gap-3 px-4 py-3.5 rounded-xl border transition-all group ${
isActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
} ${draggingId === provider.id ? 'opacity-50' : ''} ${
dragOverId === provider.id ? 'border-[var(--color-brand)] border-dashed' : ''
}`}
>
<span
draggable
onDragStart={(e) => handleProviderDragStart(e, provider.id)}
onDragEnd={clearProviderDragState}
role="button"
aria-label={t('settings.providers.dragToReorder')}
title={t('settings.providers.dragToReorder')}
className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] flex-shrink-0 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity hover:text-[var(--color-text-secondary)]"
>
drag_indicator
</span>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">

View File

@ -19,6 +19,7 @@ const {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
reorder: vi.fn(),
activate: vi.fn(),
activateOfficial: vi.fn(),
test: vi.fn(),
@ -181,3 +182,69 @@ describe('providerStore runtime refresh', () => {
expect(settingsFetchAllMock).toHaveBeenCalled()
})
})
describe('providerStore reorderProviders', () => {
beforeEach(() => {
vi.clearAllMocks()
chatStoreState.sessions = {}
runtimeStoreState.selections = {}
providersApiMock.list.mockResolvedValue({ providers: [], activeId: null })
})
it('optimistically applies the new order before the request resolves', async () => {
const a = makeProvider({ id: 'a', name: 'A' })
const b = makeProvider({ id: 'b', name: 'B' })
const c = makeProvider({ id: 'c', name: 'C' })
let resolveReorder: (value: { providers: SavedProvider[] }) => void = () => {}
providersApiMock.reorder.mockReturnValue(
new Promise((resolve) => {
resolveReorder = resolve
}),
)
const { useProviderStore } = await import('./providerStore')
useProviderStore.setState({ providers: [a, b, c], activeId: null })
const promise = useProviderStore.getState().reorderProviders(['c', 'a', 'b'])
// Optimistic update is visible immediately, before the API resolves.
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b'])
resolveReorder({ providers: [c, a, b] })
await promise
expect(providersApiMock.reorder).toHaveBeenCalledWith(['c', 'a', 'b'])
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b'])
})
it('rolls back to the previous order when the request fails', async () => {
const a = makeProvider({ id: 'a', name: 'A' })
const b = makeProvider({ id: 'b', name: 'B' })
providersApiMock.reorder.mockRejectedValue(new Error('network down'))
const { useProviderStore } = await import('./providerStore')
useProviderStore.setState({ providers: [a, b], activeId: null })
await useProviderStore.getState().reorderProviders(['b', 'a'])
// Rolls back to the pre-drag order and surfaces the error.
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['a', 'b'])
expect(useProviderStore.getState().error).toBe('network down')
})
it('refetches instead of reordering when the id set is stale', async () => {
const a = makeProvider({ id: 'a', name: 'A' })
const b = makeProvider({ id: 'b', name: 'B' })
providersApiMock.list.mockResolvedValue({ providers: [a, b], activeId: null })
const { useProviderStore } = await import('./providerStore')
useProviderStore.setState({ providers: [a, b], activeId: null })
// Only one id supplied — the list changed under us, so don't persist a bad order.
await useProviderStore.getState().reorderProviders(['a'])
expect(providersApiMock.reorder).not.toHaveBeenCalled()
expect(providersApiMock.list).toHaveBeenCalled()
})
})

View File

@ -34,6 +34,7 @@ type ProviderStore = {
createProvider: (input: CreateProviderInput) => Promise<SavedProvider>
updateProvider: (id: string, input: UpdateProviderInput) => Promise<SavedProvider>
deleteProvider: (id: string) => Promise<void>
reorderProviders: (orderedIds: string[]) => Promise<void>
activateProvider: (id: string) => Promise<void>
activateOfficial: () => Promise<void>
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) => Promise<ProviderTestResult>
@ -145,6 +146,29 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
await get().fetchProviders()
},
reorderProviders: async (orderedIds) => {
const previous = get().providers
// Optimistically reorder locally so the drag feels instant…
const byId = new Map(previous.map((p) => [p.id, p]))
const next = orderedIds
.map((id) => byId.get(id))
.filter((p): p is SavedProvider => p !== undefined)
// Bail out if the id set is stale (e.g. a provider was deleted mid-drag).
if (next.length !== previous.length) {
await get().fetchProviders()
return
}
set({ providers: next })
try {
const { providers } = await providersApi.reorder(orderedIds)
set({ providers })
} catch (err) {
// …and roll back to the pre-drag order if persistence fails. `previous`
// is the server's last-known truth, so no refetch is needed.
set({ providers: previous, error: err instanceof Error ? err.message : String(err) })
}
},
activateProvider: async (id) => {
await providersApi.activate(id)
await get().fetchProviders()

View File

@ -625,6 +625,81 @@ describe('ProviderService', () => {
})
})
// ─── reorderProviders ────────────────────────────────────────────────────
describe('reorderProviders', () => {
test('should reorder providers to match the given id order and persist it', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const c = await svc.addProvider(sampleInput({ name: 'C' }))
const result = await svc.reorderProviders([c.id, a.id, b.id])
expect(result.map((p) => p.name)).toEqual(['C', 'A', 'B'])
// Persisted order survives a fresh read
const { providers } = await svc.listProviders()
expect(providers.map((p) => p.name)).toEqual(['C', 'A', 'B'])
const config = await readProvidersConfig()
expect((config.providers as Array<{ name: string }>).map((p) => p.name)).toEqual(['C', 'A', 'B'])
})
test('should not change activeId when reordering', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
await svc.activateProvider(a.id)
await svc.reorderProviders([b.id, a.id])
const { activeId } = await svc.listProviders()
expect(activeId).toBe(a.id)
})
test('should throw 400 when orderedIds is missing a provider', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
test('should throw 400 when orderedIds contains an unknown id', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id, b.id, 'ghost-id'])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
test('should throw 400 when orderedIds contains duplicates', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id, a.id])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
})
// ─── activateProvider ────────────────────────────────────────────────────
describe('activateProvider', () => {
@ -1518,6 +1593,54 @@ describe('Providers API', () => {
expect(res.status).toBe(400)
})
// ─── PUT /api/providers/reorder ──────────────────────────────────────────
test('PUT /api/providers/reorder should reorder providers', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [b.id, a.id],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { providers: { name: string }[] }
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
})
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [a.id], // missing B
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
test('PUT /api/providers/reorder should return 400 for empty orderedIds', async () => {
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
test('POST /api/providers/reorder should be method-not-allowed', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers/reorder', {
orderedIds: [],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(405)
})
test('POST /api/providers should return 400 for invalid auto compact window', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: 'custom',

View File

@ -22,6 +22,7 @@ import {
CreateProviderSchema,
UpdateProviderSchema,
TestProviderSchema,
ReorderProvidersSchema,
} from '../types/provider.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
@ -72,6 +73,12 @@ export async function handleProvidersApi(
return Response.json({ ok: true })
}
// PUT /api/providers/reorder
if (id === 'reorder') {
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
return await handleReorder(req)
}
// /api/providers (no ID)
if (!id) {
if (req.method === 'GET') {
@ -161,6 +168,18 @@ async function handleUpdate(req: Request, id: string): Promise<Response> {
}
}
async function handleReorder(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = ReorderProvidersSchema.parse(body)
const providers = await providerService.reorderProviders(input.orderedIds)
return Response.json({ providers })
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function handleTestUnsaved(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {

View File

@ -213,6 +213,36 @@ export class ProviderService {
await this.writeIndex(index)
}
/**
* Reorder saved providers to match the given id order.
*
* The display order is simply the array order in providers.json, so reordering
* persists by rewriting the array no separate `order` field is needed.
* `orderedIds` must be a permutation of the existing provider ids (same members,
* no duplicates, none missing); otherwise the request is rejected so a stale
* client can't silently drop or duplicate a provider.
*/
async reorderProviders(orderedIds: string[]): Promise<SavedProvider[]> {
const index = await this.readIndex()
const currentIds = index.providers.map((p) => p.id)
const orderedSet = new Set(orderedIds)
const isPermutation =
orderedIds.length === currentIds.length &&
orderedSet.size === orderedIds.length &&
currentIds.every((id) => orderedSet.has(id))
if (!isPermutation) {
throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids')
}
const byId = new Map(index.providers.map((p) => [p.id, p]))
index.providers = orderedIds.map((id) => byId.get(id) as SavedProvider)
await this.writeIndex(index)
return index.providers
}
// --- Activation ---
async activateProvider(id: string): Promise<void> {

View File

@ -98,6 +98,11 @@ export const TestProviderSchema = z.object({
apiFormat: ApiFormatSchema.default('anthropic'),
})
export const ReorderProvidersSchema = z.object({
// A permutation of the existing saved provider ids, in the desired display order.
orderedIds: z.array(z.string().min(1)).min(1),
})
// TypeScript types
export type ModelMapping = z.infer<typeof ModelMappingSchema>
export type SavedProvider = z.infer<typeof SavedProviderSchema>
@ -105,6 +110,7 @@ export type ProvidersIndex = z.infer<typeof ProvidersIndexSchema>
export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
export type TestProviderInput = z.infer<typeof TestProviderSchema>
export type ReorderProvidersInput = z.infer<typeof ReorderProvidersSchema>
export interface ProviderTestStepResult {
success: boolean