refactor: 建立前端 API 客户端层,消除硬编码路径与重复 fetch

- 新增 web/src/api/ 目录(6 个文件):client.ts 基础设施 + 5 个领域模块
- 封装 18 处 fetch 调用为强类型函数,消除 17 处硬编码 /api/ 路径
- 统一 13 处重复的 fetch+json+错误处理模式
- 删除旧 web/src/components/Settings/api/expert.ts,合并到新 api/experts.ts
- ConfigPage.tsx 替换 15 处 fetch 调用,ExpertSelector.tsx 改用新 API
- 保持现有组件行为不变(静默失败仍静默,错误处理等价)
This commit is contained in:
oudecheng 2026-07-08 10:03:34 +08:00
parent a8267631b8
commit ffc1f79de4
9 changed files with 235 additions and 129 deletions

56
web/src/api/client.ts Normal file
View File

@ -0,0 +1,56 @@
// 统一的 API 端点常量,消除硬编码字符串
export const API = {
config: '/api/config',
restart: '/api/restart',
health: '/health',
mcpStatus: '/api/mcp/status',
skills: '/api/skills',
skillsToggle: '/api/skills/toggle',
subagents: '/api/subagents',
subagentsToggle: '/api/subagents/toggle',
experts: '/api/experts',
expertsToggle: '/api/experts/toggle',
expertsCreate: '/api/experts/create',
expertsUpdate: '/api/experts/update',
expertsDelete: '/api/experts/delete',
expertsSelected: '/api/experts/selected',
expertsSelect: '/api/experts/select',
} as const
/**
* fetch JSON headers
* [data, error]
*/
export async function apiFetch<T>(
endpoint: string,
options?: { method?: string; body?: unknown; signal?: AbortSignal }
): Promise<[T | null, { status: number; message: string } | null]> {
try {
const resp = await fetch(endpoint, {
method: options?.method ?? 'GET',
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined,
signal: options?.signal,
})
const data = await resp.json().catch(() => null)
if (!resp.ok) {
return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }]
}
return [data as T, null]
} catch (e) {
return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }]
}
}
/**
* GET null
*/
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
try {
const resp = await fetch(endpoint)
if (!resp.ok) return null
return await resp.json() as T
} catch {
return null
}
}

27
web/src/api/config.ts Normal file
View File

@ -0,0 +1,27 @@
import { API, apiFetch } from './client'
import type { AppConfig } from '../components/Settings/types'
export async function getAppConfig(): Promise<[AppConfig | null, string | null]> {
const [data, err] = await apiFetch<AppConfig>(API.config)
return [data, err?.message ?? null]
}
export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> {
const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } })
return err ? [false, err.message] : [true, null]
}
export async function restartGateway(): Promise<{ status: number; data: any }> {
const resp = await fetch(API.restart, { method: 'POST' })
const data = await resp.json().catch(() => ({}))
return { status: resp.status, data }
}
export async function checkHealth(): Promise<boolean> {
try {
const resp = await fetch(API.health)
return resp.ok
} catch {
return false
}
}

53
web/src/api/experts.ts Normal file
View File

@ -0,0 +1,53 @@
import { API, apiGetSilent } from './client'
import type { ExpertListResponse, ExpertItem } from '../components/Settings/types'
export function listExperts(): Promise<ExpertListResponse | null> {
return apiGetSilent<ExpertListResponse>(API.experts)
}
export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.expertsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}
export async function createExpert(payload: { name: string; description: string; body: string; scope: string }): Promise<Response> {
return fetch(API.expertsCreate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string }): Promise<Response> {
return fetch(API.expertsUpdate, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteExpert(name: string, scope: string): Promise<Response> {
const params = new URLSearchParams({ name, scope })
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' })
}
export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
const params = new URLSearchParams({ session_id: sessionId })
const resp = await fetch(`${API.expertsSelected}?${params}`)
if (!resp.ok) return { expert_name: null, expert: null }
return resp.json()
}
export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> {
const resp = await fetch(API.expertsSelect, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
return { success: true }
}

6
web/src/api/mcp.ts Normal file
View File

@ -0,0 +1,6 @@
import { API, apiGetSilent } from './client'
import type { McpStatusResponse } from '../components/Settings/types'
export function getMcpStatus(): Promise<McpStatusResponse | null> {
return apiGetSilent<McpStatusResponse>(API.mcpStatus)
}

14
web/src/api/skills.ts Normal file
View File

@ -0,0 +1,14 @@
import { API, apiGetSilent } from './client'
import type { SkillListResponse } from '../components/Settings/types'
export function listSkills(): Promise<SkillListResponse | null> {
return apiGetSilent<SkillListResponse>(API.skills)
}
export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.skillsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}

14
web/src/api/subagents.ts Normal file
View File

@ -0,0 +1,14 @@
import { API, apiGetSilent } from './client'
import type { SubagentListResponse } from '../components/Settings/types'
export function listSubagents(): Promise<SubagentListResponse | null> {
return apiGetSilent<SubagentListResponse>(API.subagents)
}
export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.subagentsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react' import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'
import { getSelectedExpert, selectExpert } from '../Settings/ConfigPage' import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'
interface ExpertItem { interface ExpertItem {
name: string name: string
@ -81,18 +81,15 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange }
const fetchExpertList = useCallback(async () => { const fetchExpertList = useCallback(async () => {
setListLoading(true) setListLoading(true)
try { const data = await listExperts()
const resp = await fetch('/api/experts') if (data) {
if (resp.ok) { // Only show enabled experts (disabled_in_scopes.length === 0)
const data = await resp.json() const enabled = (data.experts ?? []).filter(
// Only show enabled experts (disabled_in_scopes.length === 0) (e: ExpertItem) => e.disabled_in_scopes.length === 0
const enabled = (data.experts ?? []).filter( )
(e: ExpertItem) => e.disabled_in_scopes.length === 0 setExpertList(enabled)
) }
setExpertList(enabled) setListLoading(false)
}
} catch { /* ignore */ }
finally { setListLoading(false) }
}, []) }, [])
const handleToggleOpen = () => { const handleToggleOpen = () => {

View File

@ -16,7 +16,12 @@ import type {
} from './types' } from './types'
import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants' import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'
import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui' import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui'
export { getSelectedExpert, selectExpert } from './api/expert' import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'
import { listSkills, toggleSkill } from '../../api/skills'
import { listSubagents, toggleSubagent } from '../../api/subagents'
import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts'
import { getMcpStatus } from '../../api/mcp'
export { getSelectedExpert, selectExpert } from '../../api/experts'
// ── Main Component ───────────────────────────────────── // ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) {
@ -57,89 +62,53 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const [savingExpert, setSavingExpert] = useState(false) const [savingExpert, setSavingExpert] = useState(false)
const fetchMcpStatus = useCallback(async () => { const fetchMcpStatus = useCallback(async () => {
try { const data = await getMcpStatus()
const resp = await fetch('/api/mcp/status') if (data) setMcpStatus(data)
if (resp.ok) setMcpStatus(await resp.json())
} catch { /* ignore fetch errors */ }
}, []) }, [])
const fetchSkillList = useCallback(async () => { const fetchSkillList = useCallback(async () => {
setSkillListLoading(true) setSkillListLoading(true)
try { const data = await listSkills()
const resp = await fetch('/api/skills') if (data) setSkillList(data)
if (resp.ok) setSkillList(await resp.json()) setSkillListLoading(false)
} catch { /* ignore fetch errors */ }
finally { setSkillListLoading(false) }
}, []) }, [])
const toggleSkill = useCallback(async (name: string, scope: string, enabled: boolean) => { const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
const resp = await fetch('/api/skills/toggle', { return toggleSkill(name, scope, enabled)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
return resp
}, []) }, [])
const fetchSubagentList = useCallback(async () => { const fetchSubagentList = useCallback(async () => {
setSubagentListLoading(true) setSubagentListLoading(true)
try { const data = await listSubagents()
const resp = await fetch('/api/subagents') if (data) setSubagentList(data)
if (resp.ok) setSubagentList(await resp.json()) setSubagentListLoading(false)
} catch { /* ignore fetch errors */ }
finally { setSubagentListLoading(false) }
}, []) }, [])
const toggleSubagent = useCallback(async (name: string, scope: string, enabled: boolean) => { const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
const resp = await fetch('/api/subagents/toggle', { return toggleSubagent(name, scope, enabled)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
return resp
}, []) }, [])
const fetchExpertList = useCallback(async () => { const fetchExpertList = useCallback(async () => {
setExpertListLoading(true) setExpertListLoading(true)
try { const data = await listExperts()
const resp = await fetch('/api/experts') if (data) setExpertList(data)
if (resp.ok) setExpertList(await resp.json()) setExpertListLoading(false)
} catch { /* ignore fetch errors */ }
finally { setExpertListLoading(false) }
}, []) }, [])
const toggleExpert = useCallback(async (name: string, scope: string, enabled: boolean) => { const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
const resp = await fetch('/api/experts/toggle', { return toggleExpert(name, scope, enabled)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
return resp
}, []) }, [])
const createExpert = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => {
const resp = await fetch('/api/experts/create', { return createExpert(payload)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return resp
}, []) }, [])
const updateExpert = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => {
const resp = await fetch('/api/experts/update', { return updateExpert(payload)
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return resp
}, []) }, [])
const deleteExpert = useCallback(async (name: string, scope: string) => { const deleteExpertCb = useCallback(async (name: string, scope: string) => {
const resp = await fetch(`/api/experts/delete?name=${encodeURIComponent(name)}&scope=${encodeURIComponent(scope)}`, { return deleteExpert(name, scope)
method: 'DELETE',
})
return resp
}, []) }, [])
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@ -149,10 +118,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
// Load config // Load config
useEffect(() => { useEffect(() => {
fetch('/api/config').then(r => r.json()).then(data => { getAppConfig().then(([data, err]) => {
setConfig(data) if (data) setConfig(data)
if (err) setError('加载配置失败: ' + err)
setLoading(false) setLoading(false)
}).catch(e => { setError('加载配置失败: ' + e.message); setLoading(false) }) })
}, []) }, [])
// Fetch MCP status when MCP tab is selected // Fetch MCP status when MCP tab is selected
@ -190,55 +160,44 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const handleSave = async () => { const handleSave = async () => {
if (!config) return if (!config) return
setSaving(true); setError('') setSaving(true); setError('')
try { const [ok, err] = await updateAppConfig(config)
const resp = await fetch('/api/config', { if (!ok) {
method: 'PUT', setError(err || '保存失败')
headers: { 'Content-Type': 'application/json' }, } else {
body: JSON.stringify({ config }),
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.message || data.error || '保存失败')
// Config is now synced to both disk and in-memory state, // Config is now synced to both disk and in-memory state,
// so the local state is already correct. No need to re-fetch. // so the local state is already correct. No need to re-fetch.
setDirty(false) setDirty(false)
// Show restart confirmation dialog // Show restart confirmation dialog
setShowRestartDialog(true) setShowRestartDialog(true)
} catch (e: any) {
setError(e.message || '保存失败')
} finally {
setSaving(false)
} }
setSaving(false)
} }
const handleRestart = async () => { const handleRestart = async () => {
setShowRestartDialog(false) setShowRestartDialog(false)
setRestarting(true) setRestarting(true)
try { try {
const resp = await fetch('/api/restart', { method: 'POST' }) const { status, data } = await restartGateway()
const data = await resp.json() if (status === 409) {
if (resp.status === 409) {
setToast(data.message || '有任务运行中,请等待完成后再试') setToast(data.message || '有任务运行中,请等待完成后再试')
setRestarting(false) setRestarting(false)
setTimeout(() => setToast(''), 5000) setTimeout(() => setToast(''), 5000)
return return
} }
if (!resp.ok) throw new Error(data.message || '重启失败') if (status < 200 || status >= 300) throw new Error(data.message || '重启失败')
setToast('服务正在重启,页面将自动重连...') setToast('服务正在重启,页面将自动重连...')
// Poll /health until gateway is back // Poll /health until gateway is back
const poll = async () => { const poll = async () => {
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 1000)) await new Promise(r => setTimeout(r, 1000))
try { if (await checkHealth()) {
const r = await fetch('/health') const [refreshed] = await getAppConfig()
if (r.ok) { if (refreshed) setConfig(refreshed)
const refreshed = await fetch('/api/config').then(r => r.json()) setToast('服务已重启,配置已生效')
setConfig(refreshed) setRestarting(false)
setToast('服务已重启,配置已生效') setTimeout(() => setToast(''), 3000)
setRestarting(false) return
setTimeout(() => setToast(''), 3000) }
return
}
} catch { /* gateway not ready yet */ }
} }
setToast('重启超时,请手动刷新页面') setToast('重启超时,请手动刷新页面')
setRestarting(false) setRestarting(false)
@ -486,7 +445,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
}) })
try { try {
const resp = await toggleSkill(name, 'project', !currentlyEnabled) const resp = await toggleSkillCb(name, 'project', !currentlyEnabled)
const data = await resp.json() const data = await resp.json()
if (!resp.ok || !data.success) { if (!resp.ok || !data.success) {
// Rollback // Rollback
@ -629,7 +588,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
}) })
try { try {
const resp = await toggleSubagent(name, 'project', !currentlyEnabled) const resp = await toggleSubagentCb(name, 'project', !currentlyEnabled)
const data = await resp.json() const data = await resp.json()
if (!resp.ok || !data.success) { if (!resp.ok || !data.success) {
setSubagentList(prevList) setSubagentList(prevList)
@ -732,7 +691,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
}) })
try { try {
const resp = await toggleExpert(name, 'project', !currentlyEnabled) const resp = await toggleExpertCb(name, 'project', !currentlyEnabled)
const data = await resp.json() const data = await resp.json()
if (!resp.ok || !data.success) { if (!resp.ok || !data.success) {
setExpertList(prevList) setExpertList(prevList)
@ -770,7 +729,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const handleDelete = async (name: string) => { const handleDelete = async (name: string) => {
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return
try { try {
const resp = await deleteExpert(name, 'project') const resp = await deleteExpertCb(name, 'project')
const data = await resp.json() const data = await resp.json()
if (!resp.ok || !data.success) { if (!resp.ok || !data.success) {
setToast(data.error || '删除专家失败') setToast(data.error || '删除专家失败')
@ -842,13 +801,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setEditingExpertError('') setEditingExpertError('')
try { try {
const resp = isEdit const resp = isEdit
? await updateExpert({ ? await updateExpertCb({
name: editingExpert.nameField, name: editingExpert.nameField,
scope: 'project', scope: 'project',
description: editingExpert.description, description: editingExpert.description,
body: editingExpert.body, body: editingExpert.body,
}) })
: await createExpert({ : await createExpertCb({
name: editingExpert.nameField, name: editingExpert.nameField,
description: editingExpert.description, description: editingExpert.description,
body: editingExpert.body, body: editingExpert.body,

View File

@ -1,20 +0,0 @@
// Expert API helpers extracted from ConfigPage.tsx
// Used by both ConfigPage and ExpertSelector
import type { ExpertItem } from '../types'
export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`)
if (!resp.ok) return { expert_name: null, expert: null }
return resp.json()
}
export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> {
const resp = await fetch('/api/experts/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
return { success: true }
}