ServiceProvidersPage 批量导入导出方案
背景
ServiceProvidersPage.tsx 当前没有批量导入导出 API Key 的功能。用户需要手动逐个添加 API Key,效率低。需要实现两种导入导出方式,业务逻辑相同,仅交互形式不同。
功能概述
提供两套交互,共享同一套核心逻辑:
| 操作 | 交互形式 | 方向 |
|---|---|---|
| 复制 | 粘贴板 | 导出 |
| 粘贴 | 粘贴板 | 导入 |
| 导出 | 下载 JSON 文件 | 导出 |
| 导入 | 上传 JSON 文件 | 导入 |
使用场景
- 用户在同一 provider 下管理多个 API Key Entry
- 导出/复制:在当前 provider 的密钥表格中勾选若干条目,点击"复制"或"导出"
- 导入/粘贴:在当前 provider 下,从粘贴板粘贴或上传文件,批量添加 API Key Entry
- 导入导出都是针对当前选中的 provider 的
apiKeyEntries
设计决策
| 问题 | 决策 |
|---|---|
| API Key 是否脱敏 | 不脱敏,明文导出,自用场景 |
| 冲突处理 | 跳过 + 提示,同 apiKey 视为同一 entry,跳过并告知用户 |
| 导入导出粒度 | 支持部分选择,可勾选部分 entry 进行导入导出 |
| 字段范围 | 只需要 apiKey 和 baseUrl,足够了 |
导出格式
简洁的 JSON 数组格式,每个 entry 包含 apiKey、baseUrl、type 三个字段:
json
[
{ "apiKey": "sk-xxxxxxxxxxxx", "baseUrl": "https://api.example.com/v1", "type": "openai-compatibility" },
{ "apiKey": "sk-yyyyyyyyyyyy", "baseUrl": "https://api.example.com/v1", "type": "openai-compatibility" },
{ "apiKey": "sk-zzzzzzzzzzzz", "baseUrl": "https://api.example.com/v1", "type": "openai-compatibility" }
]格式说明
| 字段 | 必填 | 说明 |
|---|---|---|
apiKey | ✅ | API Key 明文 |
baseUrl | ✅ | 对应的 baseUrl |
type | ✅ | Provider 类型,当前固定为 "openai-compatibility",用于识别数据来源和未来扩展 |
格式校验
导入时通过检查数组第一个元素的字段来识别数据来源:
typescript
function isValidImportData(data: unknown): boolean {
if (!Array.isArray(data) || data.length === 0) return false;
const first = data[0];
return (
typeof first === 'object' &&
first !== null &&
'apiKey' in first &&
'baseUrl' in first &&
'type' in first
);
}核心逻辑
导出/复制 共用逻辑
输入:选中的 entries[]
输出:格式化 JSON 字符串
步骤 1: 获取选中的 apiKeyEntries(从 selectedEntryIndices 映射)
步骤 2: 构建导出数据对象(只取 apiKey 和 baseUrl 字段)
步骤 3: JSON.stringify(data, null, 2)导入/粘贴 共用逻辑
输入:JSON 字符串
输出:合并后的 apiKeyEntries[]
步骤 1: JSON.parse(text) → 失败则报错"不是有效的 JSON"
步骤 2: 检查是数组且非空 → 否则报错"没有可导入的条目"
步骤 3: 检查第一个元素包含 apiKey、baseUrl、type 字段 → 否则报错"不是有效的导出数据"
步骤 4: 遍历 entries,校验 apiKey 非空
步骤 5: 合并到当前 provider 的 apiKeyEntries:
- 已存在的 apiKey → 跳过,记录跳过数量
- 新的 apiKey → 追加
步骤 6: 通过 providersApi.saveOpenAIProviders() 保存
步骤 7: 刷新列表,toast 提示导入结果(成功 N 条,跳过 M 条)冲突处理
跳过已存在的 apiKey,不覆盖。导入时如果发现当前 provider 已有相同 apiKey 的 entry,跳过该条目,最终 toast 提示"成功导入 N 条,跳过 M 条(已存在)"。
proxyUrl / authIndex 说明
导出格式不包含 proxyUrl 和 authIndex,这是预期行为。导入等同于新增 entry,只携带 apiKey 和 baseUrl 信息。
交互细节
复制(粘贴板导出)
- 按钮位置:批量操作栏,当有选中条目时显示
- 按钮文案:
复制 () - 行为:构建 JSON →
copyToClipboard(json)→ toast "已复制 N 条记录到粘贴板" - 依赖:已有
copyToClipboard
粘贴(粘贴板导入)
- 按钮位置:批量操作栏,始终显示
- 按钮文案:
粘贴 - 行为:
- 优先尝试
navigator.clipboard.readText()(HTTPS / localhost 环境) - 降级:弹出 Modal,内含
<textarea>让用户手动粘贴 JSON,点击"确认导入"后执行导入 - 解析校验 → 合并 → 保存 → toast
- 优先尝试
- 依赖:需要在
clipboard.ts中添加readFromClipboard()函数;需要新增一个简单的 Modal/Dialog 组件(或复用项目已有的)
导出(文件下载)
- 按钮位置:批量操作栏,当有选中条目时显示
- 按钮文案:
导出 () - 行为:构建 JSON → 创建 Blob →
downloadBlob()→ toast "已导出 N 条记录" - 文件名:
{providerName}-api-keys-{timestamp}.json - 依赖:已有
downloadBlob
导入(文件上传)
- 按钮位置:批量操作栏,始终显示
- 按钮文案:
导入 - 行为:触发隐藏的
<input type="file" accept=".json">→ 读取文件内容 → 解析校验 → 合并 → 保存 → toast - 依赖:原生 File API,无需额外工具
UI 布局
在现有批量操作栏中,按钮分组排列:
[ 全选 ] | [ 删除选中 (3) ] | [ 复制 (3) ] [ 导出 (3) ] [ 粘贴 ] [ 导入 ] | [ Move Selected (3) ]- 复制/导出 按钮只在有选中条目时显示
- 粘贴/导入 按钮始终显示
- 按钮样式与现有批量操作按钮一致
实现涉及的文件
| 文件 | 改动 |
|---|---|
ServiceProvidersPage.tsx | 添加 4 个按钮和处理函数,核心导入导出逻辑 |
ServiceProvidersPage.module.scss | 按钮样式(如有需要) |
clipboard.ts | 添加 readFromClipboard() 函数 |
download.ts | 已有,无需改动 |
| Modal/Dialog 组件 | 新增或复用已有组件,用于 HTTP 环境下的手动粘贴弹窗 |
zh-CN.json | 添加中文翻译 |
en.json | 添加英文翻译 |
实现步骤
- 在
clipboard.ts中添加readFromClipboard()工具函数,内部判断navigator.clipboard是否可用,不可用返回null由调用方降级 - 在
ServiceProvidersPage.tsx中:- 提取
buildExportJson(entries)共用函数:构建导出 JSON 字符串 - 提取
parseImportJson(text)共用函数:解析校验导入 JSON - 提取
mergeEntries(existing, imported)共用函数:合并 entries(跳过已存在的 apiKey,返回 {added, skipped} 计数) - 添加
handleCopy/handlePaste/handleExport/handleImport四个处理函数 handlePaste中:优先readFromClipboard(),返回null时弹出 Modal 让用户手动粘贴- 在批量操作栏添加 4 个按钮
- 提取
- 添加粘贴降级 Modal 组件(textarea + 确认/取消按钮),可在
ServiceProvidersPage.tsx内部定义或单独文件 - 添加 i18n 翻译文案
- 测试:导出 → 清空 → 导入 验证数据完整性
测试场景
| 场景 | 预期结果 |
|---|---|
| 选中 3 条 → 复制 → 粘贴板有正确 JSON | ✅ |
| 粘贴板有正确 JSON → 粘贴 → 新 entry 追加 | ✅ |
| 粘贴板有正确 JSON → 粘贴 → 同 apiKey 跳过,提示"成功 N 条,跳过 M 条" | ✅ |
| 粘贴板内容不是 JSON → 粘贴 → 错误提示 | ✅ |
| HTTP 环境 → 点击粘贴 → 弹出 Modal textarea → 手动粘贴 → 确认导入 | ✅ |
| 选中 3 条 → 导出 → 下载 JSON 文件 | ✅ |
| 上传正确 JSON 文件 → 导入 → 合并成功 | ✅ |
| 上传非 JSON 文件 → 导入 → 错误提示 | ✅ |
| 未选中任何条目 → 复制/导出按钮不显示 | ✅ |
| 粘贴/导入按钮始终可见 | ✅ |