diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1fc398 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +# AI Video Assistant 平台 —— 开发常用命令 +# +# 用法:make <目标>,例如 make up / make db-seed。 +# 多数目标是对 docker compose 的薄封装,集中沉淀平时手敲的开发脚本。 + +# 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml) +PSQL = docker compose exec -T postgres psql -U postgres -d postgres + +.DEFAULT_GOAL := help +.PHONY: help up down restart logs api-logs db db-list db-seed db-clear db-reset + +help: ## 列出所有可用目标 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +# ---- 服务编排 ---- +up: ## 起 postgres + api + ui + docker compose up -d + +down: ## 停掉所有服务(保留数据卷) + docker compose down + +restart: ## 重建 api 容器(改了 env/CORS 后用) + docker compose up -d --force-recreate api + +logs: ## 跟随全部服务日志 + docker compose logs -f + +api-logs: ## 只看后端日志 + docker compose logs -f api + +# ---- 数据库 ---- +db: ## 进入交互式 psql + docker compose exec postgres psql -U postgres -d postgres + +db-list: ## 列出模型凭证(key 明文,仅本地调试用) + @$(PSQL) -c "SELECT id, name, type, interface_type, is_default FROM provider_credentials ORDER BY id;" + +db-seed: ## 灌入 12 条模型凭证种子(幂等) + $(PSQL) < backend/db/seed_credentials.sql + +db-clear: ## 清空模型凭证表 + $(PSQL) -c "TRUNCATE provider_credentials;" + +db-reset: db-clear db-seed ## 清空后重新灌种子 diff --git a/backend/config.py b/backend/config.py index 5b66fe1..b31ac68 100644 --- a/backend/config.py +++ b/backend/config.py @@ -40,4 +40,6 @@ DATABASE_URL = os.getenv( # ---- 服务 ---- HOST = os.getenv("HOST", "0.0.0.0") PORT = int(os.getenv("PORT", "8000")) -CORS_ORIGINS = _split(os.getenv("CORS_ORIGINS", "http://localhost:3000")) +CORS_ORIGINS = _split( + os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000") +) diff --git a/backend/db/seed_credentials.sql b/backend/db/seed_credentials.sql new file mode 100644 index 0000000..9dbb88a --- /dev/null +++ b/backend/db/seed_credentials.sql @@ -0,0 +1,26 @@ +-- 模型凭证种子数据(对应前端原 mockModels 的 12 条)。 +-- +-- 用法(从仓库根目录): +-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql +-- +-- 说明: +-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等,可重复执行不重复插入。 +-- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。 +-- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。 + +INSERT INTO provider_credentials + (id, name, model_id, type, interface_type, api_url, api_key, is_default) +VALUES + ('model_001', 'DeepSeek-V3', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-deepseek-7f3a9c2e1b', TRUE), + ('model_002', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', FALSE), + ('model_003', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', TRUE), + ('model_004', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', FALSE), + ('model_005', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', TRUE), + ('model_006', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', FALSE), + ('model_007', 'OpenAI TTS', 'tts-1', 'TTS', 'openai', 'https://api.openai.com/v1/audio/speech', 'sk-openai-tts-8f0a2c', FALSE), + ('model_008', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', TRUE), + ('model_009', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', FALSE), + ('model_010', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', TRUE), + ('model_011', 'Kimi-K2', 'moonshot-v1-8k', 'LLM', 'openai', 'https://api.moonshot.cn/v1', 'sk-kimi-7a9c1e3b5d', FALSE), + ('model_012', 'BGE Embedding', 'bge-m3', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1/embeddings', 'sk-bge-2d4f6a8c0e', FALSE) +ON CONFLICT (id) DO NOTHING; diff --git a/docker-compose.yaml b/docker-compose.yaml index e85416c..0357e55 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -42,7 +42,7 @@ services: environment: # 容器内连库:用服务名 postgres,覆盖 .env 里的 localhost DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" - CORS_ORIGINS: "http://localhost:3000" + CORS_ORIGINS: "http://localhost:3000,http://127.0.0.1:3000" ports: - "8000:8000" depends_on: diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e704545..921d94f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -3,6 +3,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ allowedDevOrigins: ["127.0.0.1"], + // 钉死项目根,避免容器内 bind-mount 时 Turbopack 把根推断成 /app/src/app。 + // __dirname 即本配置所在目录(容器内 = /app,宿主机 = ai-video/frontend)。 + turbopack: { + root: __dirname, + }, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6ef976..9d75b9a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1033,9 +1033,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1052,9 +1049,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1071,9 +1065,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1090,9 +1081,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1109,9 +1097,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1128,9 +1113,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1147,9 +1129,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1166,9 +1145,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1185,9 +1161,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1210,9 +1183,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1235,9 +1205,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1260,9 +1227,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1285,9 +1249,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1310,9 +1271,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1335,9 +1293,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1360,9 +1315,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1740,9 +1692,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1759,9 +1708,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1778,9 +1724,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1797,9 +1740,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3612,9 +3552,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3632,9 +3569,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3652,9 +3586,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3672,9 +3603,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4320,9 +4248,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4337,9 +4262,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4354,9 +4276,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4371,9 +4290,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4388,9 +4304,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4405,9 +4318,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4422,9 +4332,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4439,9 +4346,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4456,9 +4360,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4473,9 +4374,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8246,9 +8144,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8270,9 +8165,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8294,9 +8186,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8318,9 +8207,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/components/pages/ComponentsModelsPage.tsx b/frontend/src/components/pages/ComponentsModelsPage.tsx index e5269eb..bc7a7bd 100644 --- a/frontend/src/components/pages/ComponentsModelsPage.tsx +++ b/frontend/src/components/pages/ComponentsModelsPage.tsx @@ -47,11 +47,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useState, type ReactNode } from "react"; - -type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding"; - -type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { + credentialsApi, + type Credential, + type InterfaceType, + type ModelType, +} from "@/lib/api"; /** 各资源类型可选的接口类型 */ const interfaceOptionsByType: Record = { @@ -64,126 +66,8 @@ const interfaceOptionsByType: Record = { const modelTypes: ModelType[] = ["LLM", "ASR", "TTS", "Realtime", "Embedding"]; -type ModelResource = { - id: string; - name: string; - modelId: string; - type: ModelType; - interfaceType: InterfaceType; - apiUrl: string; - apiKey: string; -}; - -const mockModels: ModelResource[] = [ - { - id: "model_001", - name: "DeepSeek-V3", - modelId: "deepseek-chat", - type: "LLM", - interfaceType: "openai", - apiUrl: "https://api.deepseek.com/v1", - apiKey: "sk-deepseek-7f3a9c2e1b", - }, - { - id: "model_002", - name: "Qwen-Max", - modelId: "qwen-max", - type: "LLM", - interfaceType: "openai", - apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", - apiKey: "sk-qwen-4d8e2a6f0c", - }, - { - id: "model_003", - name: "讯飞语音识别", - modelId: "iat", - type: "ASR", - interfaceType: "xfyun", - apiUrl: "https://iat-api.xfyun.cn/v2/iat", - apiKey: "xf-asr-9b1c3d5e7a", - }, - { - id: "model_004", - name: "Paraformer 识别", - modelId: "paraformer-realtime-v2", - type: "ASR", - interfaceType: "dashscope", - apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr", - apiKey: "sk-paraformer-2e4f6a", - }, - { - id: "model_005", - name: "讯飞语音合成", - modelId: "tts", - type: "TTS", - interfaceType: "xfyun", - apiUrl: "https://tts-api.xfyun.cn/v2/tts", - apiKey: "xf-tts-6c8a0b2d4f", - }, - { - id: "model_006", - name: "CosyVoice 合成", - modelId: "cosyvoice-v1", - type: "TTS", - interfaceType: "dashscope", - apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts", - apiKey: "sk-cosyvoice-1a3c5e", - }, - { - id: "model_007", - name: "OpenAI TTS", - modelId: "tts-1", - type: "TTS", - interfaceType: "openai", - apiUrl: "https://api.openai.com/v1/audio/speech", - apiKey: "sk-openai-tts-8f0a2c", - }, - { - id: "model_008", - name: "GPT Realtime", - modelId: "gpt-4o-realtime-preview", - type: "Realtime", - interfaceType: "openai", - apiUrl: "https://api.openai.com/v1/realtime", - apiKey: "sk-realtime-3b5d7f9a1c", - }, - { - id: "model_009", - name: "Gemini Live", - modelId: "gemini-2.0-flash-live", - type: "Realtime", - interfaceType: "gemini", - apiUrl: "https://generativelanguage.googleapis.com/v1beta", - apiKey: "gm-live-5e7a9c1b3d", - }, - { - id: "model_010", - name: "text-embedding-3", - modelId: "text-embedding-3-small", - type: "Embedding", - interfaceType: "openai", - apiUrl: "https://api.openai.com/v1/embeddings", - apiKey: "sk-embed-0c2e4a6f8b", - }, - { - id: "model_011", - name: "Kimi-K2", - modelId: "moonshot-v1-8k", - type: "LLM", - interfaceType: "openai", - apiUrl: "https://api.moonshot.cn/v1", - apiKey: "sk-kimi-7a9c1e3b5d", - }, - { - id: "model_012", - name: "BGE Embedding", - modelId: "bge-m3", - type: "Embedding", - interfaceType: "openai", - apiUrl: "https://api.siliconflow.cn/v1/embeddings", - apiKey: "sk-bge-2d4f6a8c0e", - }, -]; +// 列表项类型直接复用 API 契约(camelCase,api_key 已打码) +type ModelResource = Credential; type ModelForm = { name: string; @@ -212,14 +96,41 @@ export function ComponentsModelsPage() { const [typeFilter, setTypeFilter] = useState("全部"); const [currentPage, setCurrentPage] = useState(1); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); + // 编辑时记住原记录,保存时回填 isDefault 等未在表单暴露的字段 + const [editingModel, setEditingModel] = useState(null); const [form, setForm] = useState(emptyForm); const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [deletingId, setDeletingId] = useState(null); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle"); + const loadModels = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + setModels(await credentialsApi.list()); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + // 挂载时拉取列表:这是与后端(外部系统)的同步,loadModels 内的 setLoading 属预期 + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadModels(); + }, [loadModels]); + function updateForm(key: K, value: ModelForm[K]) { setForm((prev) => ({ ...prev, [key]: value })); // 任何配置变更后,旧的测试结果不再可信,重置为待测状态 @@ -256,28 +167,73 @@ export function ComponentsModelsPage() { function openCreate() { setEditingId(null); + setEditingModel(null); setForm(emptyForm); setShowKey(false); setTestResult("idle"); + setSaveError(null); setDialogOpen(true); } function openEdit(model: ModelResource) { setEditingId(model.id); + setEditingModel(model); setForm({ name: model.name, modelId: model.modelId, type: model.type, interfaceType: model.interfaceType, apiUrl: model.apiUrl, + // 后端回传已打码,原样带回 → 不修改则保留旧 key(写时哨兵) apiKey: model.apiKey, }); setShowKey(false); setTestResult("idle"); + setSaveError(null); setDialogOpen(true); } - const filteredModels = mockModels.filter((model) => { + async function handleSave() { + setSaving(true); + setSaveError(null); + const payload = { + name: form.name.trim(), + modelId: form.modelId.trim(), + type: form.type, + interfaceType: form.interfaceType, + apiUrl: form.apiUrl.trim(), + apiKey: form.apiKey, + // 表单未暴露 isDefault,编辑时沿用原值,新建默认 false + isDefault: editingModel?.isDefault ?? false, + }; + try { + if (editingId) { + await credentialsApi.update(editingId, payload); + } else { + await credentialsApi.create(payload); + } + setDialogOpen(false); + await loadModels(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "保存失败"); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + setDeletingId(id); + try { + await credentialsApi.remove(id); + await loadModels(); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + const filteredModels = models.filter((model) => { if (typeFilter !== "全部" && model.type !== typeFilter) { return false; } @@ -448,8 +404,20 @@ export function ComponentsModelsPage() { align="end" className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1" > - - + { + event.preventDefault(); + void handleDelete(model.id); + }} + > + {deletingId === model.id ? ( + + ) : ( + + )} 删除 @@ -458,13 +426,41 @@ export function ComponentsModelsPage() { ))} - {filteredModels.length === 0 && ( + {loading && ( +
+ + 正在加载模型列表… +
+ )} + + {!loading && loadError && (
-
- 未找到匹配的模型 +
+ 加载失败
- 请调整关键词或筛选条件后再试。 + {loadError} +
+ +
+ )} + + {!loading && !loadError && filteredModels.length === 0 && ( +
+
+ {models.length === 0 ? "暂无模型资源" : "未找到匹配的模型"} +
+
+ {models.length === 0 + ? "点击右上角「添加模型」创建第一个资源。" + : "请调整关键词或筛选条件后再试。"}
)} @@ -716,22 +712,33 @@ export function ComponentsModelsPage() { 连接失败 )} + {saveError && ( + + + {saveError} + + )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..8363ed7 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,71 @@ +/** + * 后端 API 客户端。基址走 NEXT_PUBLIC_API_BASE,缺省指向本地后端 :8000。 + * + * JSON 契约与后端 schemas.py 对齐(camelCase),所以返回体可直接喂给页面 state。 + * 注意:api_key 读取时后端永远打码,写回打码占位符表示"不改 key"(写时哨兵)。 + */ + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; + +export type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding"; +export type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini"; + +/** 列表/详情返回(api_key 已打码) */ +export type Credential = { + id: string; + name: string; + modelId: string; + type: ModelType; + interfaceType: InterfaceType; + apiUrl: string; + apiKey: string; + isDefault: boolean; +}; + +/** 创建/更新入参。apiKey 留空或打码值 → 后端保留旧 key */ +export type CredentialUpsert = { + name: string; + modelId: string; + type: ModelType; + interfaceType: InterfaceType; + apiUrl: string; + apiKey: string; + isDefault: boolean; +}; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!res.ok) { + let detail = `请求失败 (${res.status})`; + try { + const body = (await res.json()) as { detail?: string }; + if (body?.detail) detail = body.detail; + } catch { + // 响应体不是 JSON,沿用默认错误信息 + } + throw new Error(detail); + } + // 204 / 空响应保护 + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +export const credentialsApi = { + list: () => request("/api/credentials"), + create: (body: CredentialUpsert) => + request("/api/credentials", { + method: "POST", + body: JSON.stringify(body), + }), + update: (id: string, body: CredentialUpsert) => + request(`/api/credentials/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }), + remove: (id: string) => + request<{ ok: boolean }>(`/api/credentials/${id}`, { method: "DELETE" }), +};