diff --git a/docs/BOARD.md b/docs/BOARD.md index 261e65dc..922612a2 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -96,6 +96,7 @@ | ON-4 网络错误文案走查 + i18n pass:三轴并行审读(错误/一致性/僵尸);45 键修订(错误归因与下一步动作、术语统一工作室/提示词/服务商/图片、省略号统一);删 canvasNodeType* 6 僵尸键 | #197 | | GAP-7 Inspector 测试欠账收口(预设点选应用+成本文案精确断言)+ ON-5 五屏空态 golden(Studio empty/error、Canvas empty、Gallery empty、Settings;ubuntu 铸线)——第 10 条(ON-3/4/GAP-7/ON-5)收官;**余 GAP-1 整卡、GAP-3 余量未清**(评审 P1 纠偏:勿宣「全部收官」) | #198 | | GAP-3 余量收口(24 站点审计:方向读错并入横幅链;raw toString 上屏收敛——设置页探测诊断行为有意例外;base style 编辑器读错中止防空覆盖(评审 P2-3);InkErrorBanner.onRetry 死参数删除;**InkAsyncSlot 判 YAGNI**——列表槽位 LB-06 已全收口) | #199 | +| **GAP-1 设置页 Custom Provider 编辑 UI(上线前必做最后一卡)**:CustomProviderStore 写侧(raw 保真+损坏拒写+原子写);校验抽 core 纯函数双端共用;列表+表单+删除确认+重启生效常驻条;API Keys custom:* 行 displayName 顺带修——**随本卡合入,上线前必做 1-10 全部落地** | #200 | ## M1 补遗(审计发现的悬空项) @@ -153,6 +154,7 @@ | 三大重操作互斥只在导入侧单向查(LB-12 拍板 9):还原/导出入口不查 projectImportBusyProvider——导入进行中仍可点还原 | 🅿️ | 反向补查三行;或统一 heavyOperationBusyProvider 归一三个 busy 位 | | 导入补偿删除失败→projects/{uuid} 孤儿目录无回收路径(#192 评审 P3-2:无 .import- 前缀 sweep 不认,reaper 又 DRY-RUN);另记拍板 4 三处字面偏差(U+FFFD 奇名可过/最终路径长未预检/isWithin 代 resolveInProject)均安全失败 | 🅿️ | 随 LB-13 reaper 转真删同窗:无行背书目录纳入回收;字面偏差随安全面复审顺修 | | **迁移纪律备忘(#192 评审 P3-6)**:导入的列白名单过滤依赖「迁移只加可空/有默认列」——将来任何「新增 NOT NULL 无默认」迁移会让旧项目包导入必炸 | 🅿️ | ADR-0012 补一句:新增列必须可空或带默认,否则同时给导入侧加填充逻辑 | +| GAP-1 评审 P3 残留(#200):①unknownTemplate 在 UI 错误文案映射到 InvalidId 键(当前不可达——模板恒下拉;改自由输入即活雷,补专用键或注释);②_openEditor 读失败报「保存失败」文案微错位;③写无顺序化(模态门控下重合概率趋零,硬化=_queue.then 串行链);④_parseEntry seenIds 在 template/url 校验前占坑,被拒条目致后续同 id 合法条目误判 duplicate(既有债非本卡引入);⑤section 内 provider 定义应迁 features/settings/providers/(风格) | 🅿️ | 均低害;①随模板扩展窗强制处理 | | GAP-3 评审 P3 残留(#199):①方向读错期 lane_toolbar 置灰未做(二元域无损毁,但 toggle 到不了 horizontal 的怪异 UX);②横幅三源 `??` 链+单 `_dismissed` 遮蔽——关掉 edges 错后并发 lanes/direction 错不上屏(改集合);③非 InkError→errorUnknown 后无任何日志线索(此前 raw toString 至少可报障),建议 error 分支补 log 或 ProviderObserver.providerDidFail | 🅿️ | ①②低害 UX;③可观测性,随日志面收口 | | ON-5 评审 P3 残留(#198):①golden sentinel 单点——删 studio_empty.png 五测静默 skip 而 node_card 仍 ran>0 骗过整 job 守卫;评审提的 `skipped>0&&baselines>0→fail` 会误伤增量铸线 bootstrap(本 PR 自身流程即反例),需更细粒度方案;②成本断言 0.01×1 测不出漏乘 batch,补 maxBatchSize>1→\$0.02 用例;③ci.yml 与 update-goldens.yml 双 pin FLUTTER_VERSION 升级必须同步+重铸 | 🅿️ | ①设计再议 ②一测的事 ③升级 checklist 项 | | ON-3 评审 P3 残留(#196):卸载 ffmpeg 后设置页旧 Available 滞留(hit 缓存 app 级,设置页不调 invalidate;导出失败路径会自愈);PATH 命中显示裸 `ffmpeg` 当路径 | 🅿️ | 低害:重启/导出失败自愈;若做刷新按钮同窗顺修 | diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 02875107..d4ba6b64 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -172,7 +172,9 @@ W1(LB-03/04/06/17)→ W2(LB-07/09/13/14/16)→ W3(LB-10/11/15/18)→ W4(LB-12 6. **GAP-3 AsyncValue error 态统一**(M,新共享件 InkAsyncSlot;清单含复审补充的 library_sidebar:45 与 canvas_view 裸 toString 站点;LB-06/#166 已合,余量归本卡)+ **GAP-4 slot error 文案**(S) ✅ **#167 已合**(Tooltip + danger 文案)。 -7. **GAP-1 设置页 Custom Provider 编辑 UI**(L,守住"重启生效"边界)。 +7. **GAP-1 设置页 Custom Provider 编辑 UI**(L)✅ **#200 已合**(Store 写侧 raw + 保真+损坏拒写+原子写;校验纯函数双端共用;列表+表单+删除确认+重启生效常驻条; + API Keys custom:* displayName 顺带修——"重启生效"边界守住,不碰 registry 变异)。 8. **GAP-2 软删项目回收站 UI**(M)✅ **#190 已合**(≡LB-15:sidebar 真回收站入口取代 ARCHIVE 死行、 管理画布已删区;首版无永久删除)。 9. **PL-6 窗口状态记忆**(S–M)✅ **#170 已合**(退出捕获+启动恢复+多显示器 clamp)。 @@ -185,7 +187,10 @@ W1(LB-03/04/06/17)→ W2(LB-07/09/13/14/16)→ W3(LB-10/11/15/18)→ W4(LB-12 **GAP-3 余量**✅ **#199 已合**(24 站点审计:泳道方向读错并入横幅链;raw toString 上屏收敛,探测诊断行有意例外;base style 读错中止防空覆盖;InkAsyncSlot 判 YAGNI——第 6 条关闭)。 - **清单余量(未收官)**:仅第 7 条 GAP-1 设置页 Custom Provider 编辑 UI(L,整卡未做)。 + **第 7 条 GAP-1**✅ #200——**上线前必做 1-10 全部落地**(2026-07-18 逐条核对: + 1 CV-1/#174,2 ON-1+2/#175+#195,3 GAP-8/#164,4 PL-4a/#168,5 PL-2+PL-1/#176+#174, + 6 GAP-3/#166+#199,7 GAP-1/#200,8 GAP-4/#167,9 PL-6/#170,10 ON-3/4/GAP-7/ON-5 + /#196+#197+#198)。 **上线后首迭代起**:PL-4b undo 栈、CV-4 左工具栏实装/CV-5 视口 chrome(zoom 指示+fit,minimap 后置)、 CV-2/CV-3(节点色条/Inspector 浮动,拍板后随时)、PL-3 右键菜单(建议尽早)、PL-5 框选群拖、 diff --git a/docs/superpowers/plans/2026-07-18-gap-1-custom-providers-ui.md b/docs/superpowers/plans/2026-07-18-gap-1-custom-providers-ui.md new file mode 100644 index 00000000..3dcbaee2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-gap-1-custom-providers-ui.md @@ -0,0 +1,65 @@ +# GAP-1 设置页 Custom Provider 编辑 UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 设置页可视化增删改 custom_providers.json(列表+5 字段表单+删除确认+内联校验),守住「重启生效」边界;顺带 API Keys 行对 `custom:*` 显示 displayName。 + +**Architecture:** 读写分离——`CustomProviderSource`(既有,registry 消费,会话内不变)不动;新增 `CustomProviderStore` 接口(fresh list/upsert/remove),由 CustomProvidersFileService 一并实现。校验抽成 core 纯函数(服务解析与 UI 内联复用同一事实源)。写回走 raw 条目保真 + `.partial`→rename 原子盘。 + +**Tech Stack:** 既有 kProviderProtocolTemplates 白名单、AppPaths.config、InkCard/InkButton 组件、ARB。 + +## Global Constraints + +- **不碰 registry 变异深水区**:会话内 configs 不变;所有写操作仅落盘,UI 常驻「重启后生效」提示。 +- **写回保真(本卡最大风险面,拍板③)**:写路径重读 raw 数组,按 id 匹配替换/删除/追加;**读侧剔除的非法/未知 raw 条目原位保留**——编辑一条不销毁用户手编的坏条目。 +- **损坏文件写保护(拍板④)**:文件损坏(不可解析/顶层非数组)时写操作**拒绝执行**抛 LocalIOError(绝不覆盖用户可能想抢救的文件),UI 呈现错误。 +- 原子写:`.partial`→rename;2 空格缩进 pretty JSON(保持可手编)。 +- i18n:新 ARB 键 en/zh 全覆盖 + gen-l10n 同 commit。 +- 校验=PROVIDER-API §13.1 全集:id 正则/唯一/不撞内置;template 白名单下拉;base_url 绝对 http(s) 无 query/fragment/userinfo;5 字段非空。 + +--- + +### Task 1: 校验纯函数抽取 + CustomProviderStore 写侧(TDD) + +**Files:** +- Create: `lib/core/models/custom_provider_validation.dart`(纯函数,无 IO/Flutter) +- Create: `lib/core/interfaces/custom_provider_store.dart` +- Modify: `lib/services/custom_providers_file_service.dart`(_parseEntry 改用共享校验;实现 Store) +- Modify: `lib/core/di/providers.dart`(customProviderStoreProvider) +- Test: `test/core/models/custom_provider_validation_test.dart`、`test/services/custom_providers_file_service_test.dart`(扩写侧) + +**Interfaces:** +- Produces: `CustomProviderFieldError` enum(emptyField/invalidId/duplicateId/reservedId/unknownTemplate/invalidBaseUrl);`CustomProviderFieldError? validateId(String, {required Set takenIds, required Set reservedProviderIds})` / `validateBaseUrl(String)` / `validateTemplate(String)` / `validateRequired(String)`;`abstract class CustomProviderStore { Future> list(); Future upsert(CustomProviderConfig c); Future remove(String id); }` +- upsert 语义:raw 数组中首个 `id` 字段==目标者原位替换,无匹配则追加;remove 同匹配删除;其余 raw 条目逐字保留。 + +- [ ] 红测:校验纯函数全分支;写侧=upsert 新增/替换、remove、**保真**(含一条非法 raw 条目的文件写后该条仍在)、损坏文件拒写抛 LocalIOError、原子性(.partial 不残留)、写后 fresh list 可读回。 +- [ ] 实现;`flutter analyze` + 目标测试绿;commit `feat(providers): GAP-1 校验纯函数 + custom provider 写侧` + +### Task 2: 设置页 CustomProvidersSection + ARB(TDD) + +**Files:** +- Create: `lib/features/settings/widgets/custom_providers_section.dart` +- Modify: `lib/features/settings/settings_screen.dart`(挂载,置 API Keys 之后) +- Modify: ARB en/zh(settingsCustomProviders* ~18 键)+ generated/ +- Test: `test/features/settings/custom_providers_section_test.dart`(fake Store) + +**Interfaces:** +- Consumes: customProviderStoreProvider;`customProvidersListProvider = FutureProvider.autoDispose`(store.list() fresh 读,写后 invalidate)。 +- 结构:标题+hint;条目行(displayName + `custom:` + template 徽标 + 编辑/删除 icon);空态文案;「Add provider」按钮;表单对话框(id 仅新增可编/编辑锁定,display_name,template 下拉,base_url,model_id;内联错误经校验纯函数);删除确认对话框;会话内发生过写操作后常驻 warning 条「重启后生效」。 + +- [ ] 红测:列表渲染/添加流(含 id 冲突与 base_url 带 query 内联报错不提交)/编辑流(id 锁定)/删除确认/写后 restart 条出现/store 抛 LocalIOError(损坏文件)→ 错误呈现不崩。 +- [ ] 实现 + gen-l10n;全量闸门;commit `feat(settings): GAP-1 Custom Providers 编辑区` + +### Task 3: API Keys 行 displayName 顺带修 + docs + PR + +**Files:** +- Modify: `lib/features/settings/widgets/api_keys_section.dart`(`custom:*` 行经 providerCapabilitiesListProvider 取 displayName) +- Test: 既有 api_keys_section_test 扩一例 +- Modify: docs/BOARD.md、docs/MASTERPLAN.md(GAP-1 登记,上线前清单收官宣称**须按 #198 教训如实核对**) + +- [ ] displayName 测试红→绿;全量闸门;PR → 对抗评审(关注:写回保真/损坏拒写/校验双源一致/重启边界泄漏)→ 修 P1/P2 → CI 一次性核验 → squash merge。 + +## Self-Review + +- 卡面验收逐条对齐:不碰 json 完成增删改 ✓(Task 1/2);非法输入内联报错 ✓;损坏 json 不崩 ✓(拒写+呈现);重启后下拉出现=既有 bootstrap 链天然满足 ✓;fake Source/Store widget 测试 ✓;顺带修 displayName ✓(Task 3)。 +- 类型一致:CustomProviderFieldError 名称在 Task 1/2 一致;Store 三方法签名一致 ✓。 diff --git a/lib/core/di/custom_providers.dart b/lib/core/di/custom_providers.dart index c8723df4..7ace8664 100644 --- a/lib/core/di/custom_providers.dart +++ b/lib/core/di/custom_providers.dart @@ -7,8 +7,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/custom_providers_file_service.dart'; import '../interfaces/custom_provider_source.dart'; +import '../interfaces/custom_provider_store.dart'; final customProviderSourceProvider = Provider( (ref) => const EmptyCustomProviderSource(), name: 'customProviderSourceProvider', ); + +/// 编辑侧(GAP-1):写仅落盘、重启生效。main 用同一 CustomProvidersFileService +/// 实例覆盖;默认抛——设置页测试必须显式注入 fake。 +final customProviderStoreProvider = Provider( + (ref) => throw UnimplementedError( + 'customProviderStoreProvider must be overridden in main()', + ), + name: 'customProviderStoreProvider', +); diff --git a/lib/core/interfaces/custom_provider_store.dart b/lib/core/interfaces/custom_provider_store.dart new file mode 100644 index 00000000..ea82a526 --- /dev/null +++ b/lib/core/interfaces/custom_provider_store.dart @@ -0,0 +1,21 @@ +// CustomProviderStore:custom_providers.json 的编辑侧契约(GAP-1)。 +// +// 与只读的 CustomProviderSource 分离——registry 消费的会话内快照不变, +// 本接口的写操作仅落盘,重启后生效(不做运行时 registry 变异)。 +// +// 保真约束:写路径按 raw 条目操作,读侧剔除的非法/未知条目原位保留—— +// 编辑一条绝不销毁用户手编的其他条目。文件损坏(不可解析/顶层非数组)时 +// 写操作拒绝执行抛 LocalIOError,绝不覆盖用户可能想抢救的文件。 + +import '../models/custom_provider_config.dart'; + +abstract class CustomProviderStore { + /// 全新读盘的合法条目列表(不影响、也不使用会话内快照)。 + Future> list(); + + /// raw 数组中首个 `id` 字段==目标者原位替换;无匹配则追加到末尾。 + Future upsert(CustomProviderConfig config); + + /// 按 `id` 字段匹配删除;无匹配为 no-op。 + Future remove(String id); +} diff --git a/lib/core/models/custom_provider_validation.dart b/lib/core/models/custom_provider_validation.dart new file mode 100644 index 00000000..0a36f2b2 --- /dev/null +++ b/lib/core/models/custom_provider_validation.dart @@ -0,0 +1,78 @@ +// custom provider 字段校验纯函数(PROVIDER-API §13.1)——服务解析与设置页 +// 内联校验共用同一事实源,规则改动只此一处。无 IO、无 Flutter 依赖。 + +import 'custom_provider_config.dart'; +import 'provider_protocol_template.dart'; + +/// 字段级校验失败原因;UI 据此映射 l10n 文案,服务据此写 WARN reason。 +enum CustomProviderFieldError { + emptyField, + invalidId, + duplicateId, + reservedId, + unknownTemplate, + invalidBaseUrl, +} + +/// `id` 白名单模式——进 SecureStorage key / jobs.provider_id / 日志,收紧字符集。 +final RegExp kCustomProviderIdPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9_-]*$'); + +/// 非空(trim 后)通用校验。 +CustomProviderFieldError? validateRequired(String value) => + value.trim().isEmpty ? CustomProviderFieldError.emptyField : null; + +/// id 校验:非空 → 模式 → 文件内唯一 → 不撞内置 providerId。 +/// [takenIds] 为文件内其他条目的 id 集合(编辑自身时调用方先剔除自身)。 +CustomProviderFieldError? validateId( + String value, { + required Set takenIds, + required Set reservedProviderIds, +}) { + final id = value.trim(); + if (id.isEmpty) return CustomProviderFieldError.emptyField; + if (!kCustomProviderIdPattern.hasMatch(id)) { + return CustomProviderFieldError.invalidId; + } + if (takenIds.contains(id)) return CustomProviderFieldError.duplicateId; + if (reservedProviderIds.contains('$kCustomProviderIdPrefix$id')) { + return CustomProviderFieldError.reservedId; + } + return null; +} + +/// template 校验:白名单之一。 +CustomProviderFieldError? validateTemplate(String value) { + final t = value.trim(); + if (t.isEmpty) return CustomProviderFieldError.emptyField; + if (!kProviderProtocolTemplates.containsKey(t)) { + return CustomProviderFieldError.unknownTemplate; + } + return null; +} + +/// base_url 校验:绝对 http(s)、有 host、无 query/fragment/userinfo +/// (Dio baseUrl 为字符串拼接,带 query 必产坏请求)。 +CustomProviderFieldError? validateBaseUrl(String value) { + final raw = value.trim(); + if (raw.isEmpty) return CustomProviderFieldError.emptyField; + final uri = Uri.tryParse(raw); + if (uri == null || + !uri.isAbsolute || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty || + uri.hasQuery || + uri.hasFragment || + uri.userInfo.isNotEmpty) { + return CustomProviderFieldError.invalidBaseUrl; + } + return null; +} + +/// base_url 规范化:尾部 `/` 剔除(Dio baseUrl + '/path' 拼接避免双斜杠)。 +String normalizeBaseUrl(String url) { + var out = url.trim(); + while (out.endsWith('/')) { + out = out.substring(0, out.length - 1); + } + return out; +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 4f1ea6f4..8bc0d120 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -15,6 +15,7 @@ import 'widgets/about_section.dart'; import 'widgets/api_keys_section.dart'; import 'widgets/backup_section.dart'; import 'widgets/canvas_appearance_section.dart'; +import 'widgets/custom_providers_section.dart'; import 'widgets/diagnostics_section.dart'; import 'widgets/language_section.dart'; import 'widgets/storage_path_section.dart'; @@ -44,6 +45,8 @@ class SettingsScreen extends ConsumerWidget { children: [ ApiKeysSection(), SizedBox(height: InkSpacing.xl), + CustomProvidersSection(), + SizedBox(height: InkSpacing.xl), ThemeSection(), SizedBox(height: InkSpacing.xl), CanvasAppearanceSection(), diff --git a/lib/features/settings/widgets/api_keys_section.dart b/lib/features/settings/widgets/api_keys_section.dart index 4f85359a..9c7310ef 100644 --- a/lib/features/settings/widgets/api_keys_section.dart +++ b/lib/features/settings/widgets/api_keys_section.dart @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants/secure_storage_keys.dart'; import '../../../core/di/providers.dart'; import '../../../core/errors/ink_error.dart'; +import '../../../core/models/custom_provider_config.dart'; import '../../../core/models/provider_capabilities.dart'; import '../../../l10n/l10n_x.dart'; import '../../../theme/app_theme.dart'; @@ -133,7 +134,13 @@ class _ApiKeyRowState extends ConsumerState<_ApiKeyRow> { final keyState = ref.watch(apiKeyScopeControllerProvider(_providerId)); final loading = keyState.isLoading; final isSet = keyState.valueOrNull ?? false; - final label = SecureStorageKeys.displayNameOf(widget.scope); + // custom:* 行显示配置里的 displayName(GAP-1 顺带修——此前裸 custom:)。 + final String? customDisplayName = widget.members.first.displayName; + final label = widget.scope.startsWith(kCustomProviderIdPrefix) && + customDisplayName != null && + customDisplayName.isNotEmpty + ? customDisplayName + : SecureStorageKeys.displayNameOf(widget.scope); final memberIds = widget.members.map((c) => c.providerId).join(' / '); final showMembers = diff --git a/lib/features/settings/widgets/custom_providers_section.dart b/lib/features/settings/widgets/custom_providers_section.dart new file mode 100644 index 00000000..806b341a --- /dev/null +++ b/lib/features/settings/widgets/custom_providers_section.dart @@ -0,0 +1,449 @@ +// CustomProvidersSection — 设置页自定义服务商编辑区(GAP-1)。 +// +// 列表 + 5 字段表单对话框(校验=custom_provider_validation 纯函数,与文件 +// 服务解析同一事实源)+ 删除确认。写仅落盘、重启生效——会话内发生过写 +// 操作后常驻「重启后生效」提示条;不碰 registry 变异。 +// 损坏文件:Store 拒写抛 LocalIOError → snackbar 提示,绝不覆盖。 + +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/di/custom_providers.dart'; +import '../../../core/di/providers.dart'; +import '../../../core/errors/ink_error.dart'; +import '../../../core/models/custom_provider_config.dart'; +import '../../../core/models/custom_provider_validation.dart'; +import '../../../core/models/provider_protocol_template.dart'; +import '../../../l10n/l10n_x.dart'; +import '../../../theme/app_theme.dart'; +import '../../../theme/components/ink_button.dart'; +import '../../../theme/components/ink_card.dart'; +import '../../../theme/components/ink_input.dart'; +import '../../../theme/tokens.dart'; +import '../../../theme/typography.dart'; + +/// 编辑区 fresh 读盘列表(不使用会话内快照;写后 invalidate 刷新)。 +final customProvidersListProvider = + FutureProvider.autoDispose>( + (ref) => ref.watch(customProviderStoreProvider).list(), + name: 'customProvidersListProvider', +); + +/// 会话内是否发生过写操作——驱动「重启后生效」常驻提示。 +/// 非 autoDispose:离开设置页再回来提示仍在(重启前始终为真)。 +final customProvidersDirtyProvider = StateProvider( + (ref) => false, + name: 'customProvidersDirtyProvider', +); + +class CustomProvidersSection extends ConsumerWidget { + const CustomProvidersSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.inkColors; + final typo = context.inkTypography; + final listAsync = ref.watch(customProvidersListProvider); + final dirty = ref.watch(customProvidersDirtyProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.settingsCustomProvidersSection, + style: typo.title.copyWith(color: colors.fg1), + ), + const SizedBox(height: InkSpacing.xs), + Text( + context.l10n.settingsCustomProvidersHint, + style: typo.caption.copyWith(color: colors.fg3), + ), + if (dirty) ...[ + const SizedBox(height: InkSpacing.sm), + _RestartNotice(colors: colors, typo: typo), + ], + const SizedBox(height: InkSpacing.sm), + listAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + // 列表读失败(损坏文件等):呈现原因;增删入口仍在(Store 拒写兜底)。 + error: (e, _) => Text( + l10nAsyncError(context, e), + style: typo.body.copyWith(color: colors.danger), + ), + data: (configs) => configs.isEmpty + ? Text( + context.l10n.settingsCustomProvidersEmpty, + style: typo.body.copyWith(color: colors.fg3), + ) + : Column( + children: [ + for (final c in configs) ...[ + _ProviderRow(config: c), + const SizedBox(height: InkSpacing.xs), + ], + ], + ), + ), + const SizedBox(height: InkSpacing.sm), + InkButton( + label: context.l10n.settingsCustomProvidersAdd, + variant: InkButtonVariant.secondary, + icon: Icons.add, + onPressed: () => unawaited(_openEditor(context, ref, existing: null)), + ), + ], + ); + } +} + +class _RestartNotice extends StatelessWidget { + const _RestartNotice({required this.colors, required this.typo}); + final InkColors colors; + final InkTypography typo; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surface2, + border: Border.all(color: colors.warning), + borderRadius: BorderRadius.circular(InkRadius.md), + ), + child: Padding( + padding: const EdgeInsets.all(InkSpacing.sm), + child: Text( + context.l10n.settingsCustomProvidersRestartNotice, + style: typo.caption.copyWith(color: colors.warning), + ), + ), + ); + } +} + +class _ProviderRow extends ConsumerWidget { + const _ProviderRow({required this.config}); + final CustomProviderConfig config; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.inkColors; + final typo = context.inkTypography; + return InkCard( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + config.displayName, + style: typo.body.copyWith(color: colors.fg1), + ), + const SizedBox(height: InkSpacing.xs), + Text( + '${config.providerId} · ${config.template} · ${config.baseUrl}', + style: typo.caption.copyWith(color: colors.fg3), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + tooltip: context.l10n.settingsCustomProviderEditTitle, + icon: Icon(Icons.edit_outlined, + size: InkSpacing.md, color: colors.fg2), + onPressed: () => + unawaited(_openEditor(context, ref, existing: config)), + ), + IconButton( + tooltip: context.l10n.settingsCustomProviderDeleteTitle, + icon: Icon(Icons.delete_outline, + size: InkSpacing.md, color: colors.danger), + onPressed: () => unawaited(_confirmDelete(context, ref, config)), + ), + ], + ), + ); + } +} + +Future _confirmDelete( + BuildContext context, + WidgetRef ref, + CustomProviderConfig config, +) async { + final l10n = context.l10n; + final failedMsg = l10n.settingsCustomProviderSaveFailed; + final store = ref.read(customProviderStoreProvider); + final dirty = ref.read(customProvidersDirtyProvider.notifier); + final bool? confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.settingsCustomProviderDeleteTitle), + content: Text(l10n.settingsCustomProviderDeleteBody(config.displayName)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.settingsCustomProviderDeleteConfirm), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await store.remove(config.id); + } on InkError { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(failedMsg))); + return; + } + dirty.state = true; + // unmount 后 autoDispose 的 list provider 下次挂载天然重建,无需 invalidate。 + if (context.mounted) ref.invalidate(customProvidersListProvider); +} + +Future _openEditor( + BuildContext context, + WidgetRef ref, { + required CustomProviderConfig? existing, +}) async { + final l10n = context.l10n; + final failedMsg = l10n.settingsCustomProviderSaveFailed; + final store = ref.read(customProviderStoreProvider); + final dirty = ref.read(customProvidersDirtyProvider.notifier); + // 冲突校验基线:现存文件条目(编辑时剔除自身)+ 内置 providerId。 + final List current; + try { + current = await store.list(); + } on InkError { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(failedMsg))); + return; + } + final takenIds = { + for (final c in current) + if (c.id != existing?.id) c.id, + }; + final reserved = { + for (final caps in kAllProviderCapabilities) caps.providerId, + }; + if (!context.mounted) return; + final CustomProviderConfig? result = await showDialog( + context: context, + builder: (ctx) => _EditorDialog( + existing: existing, + takenIds: takenIds, + reservedProviderIds: reserved, + ), + ); + if (result == null) return; + try { + await store.upsert(result); + } on InkError { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(failedMsg))); + return; + } + dirty.state = true; + if (context.mounted) ref.invalidate(customProvidersListProvider); +} + +/// 5 字段表单:Save 时全量校验,错误内联呈现,通过才 pop 结果。 +class _EditorDialog extends StatefulWidget { + const _EditorDialog({ + required this.existing, + required this.takenIds, + required this.reservedProviderIds, + }); + + final CustomProviderConfig? existing; + final Set takenIds; + final Set reservedProviderIds; + + @override + State<_EditorDialog> createState() => _EditorDialogState(); +} + +class _EditorDialogState extends State<_EditorDialog> { + late final TextEditingController _id = + TextEditingController(text: widget.existing?.id ?? ''); + late final TextEditingController _name = + TextEditingController(text: widget.existing?.displayName ?? ''); + late final TextEditingController _baseUrl = + TextEditingController(text: widget.existing?.baseUrl ?? ''); + late final TextEditingController _modelId = + TextEditingController(text: widget.existing?.modelId ?? ''); + late String _template = widget.existing?.template ?? + kProviderProtocolTemplates.keys.first; + + final Map _errors = {}; + + @override + void dispose() { + _id.dispose(); + _name.dispose(); + _baseUrl.dispose(); + _modelId.dispose(); + super.dispose(); + } + + String _errorText(BuildContext context, CustomProviderFieldError e) => + switch (e) { + CustomProviderFieldError.emptyField => + context.l10n.settingsCustomProviderErrorRequired, + CustomProviderFieldError.invalidId => + context.l10n.settingsCustomProviderErrorInvalidId, + CustomProviderFieldError.duplicateId => + context.l10n.settingsCustomProviderErrorDuplicateId, + CustomProviderFieldError.reservedId => + context.l10n.settingsCustomProviderErrorReservedId, + CustomProviderFieldError.unknownTemplate => + context.l10n.settingsCustomProviderErrorInvalidId, + CustomProviderFieldError.invalidBaseUrl => + context.l10n.settingsCustomProviderErrorInvalidBaseUrl, + }; + + void _save() { + final errors = {}; + final idErr = widget.existing != null + ? null // 编辑态 id 锁定,免校验 + : validateId( + _id.text, + takenIds: widget.takenIds, + reservedProviderIds: widget.reservedProviderIds, + ); + if (idErr != null) errors['id'] = idErr; + final nameErr = validateRequired(_name.text); + if (nameErr != null) errors['name'] = nameErr; + final tplErr = validateTemplate(_template); + if (tplErr != null) errors['template'] = tplErr; + final urlErr = validateBaseUrl(_baseUrl.text); + if (urlErr != null) errors['base_url'] = urlErr; + final modelErr = validateRequired(_modelId.text); + if (modelErr != null) errors['model_id'] = modelErr; + + if (errors.isNotEmpty) { + setState(() { + _errors + ..clear() + ..addAll(errors); + }); + return; + } + Navigator.of(context).pop(CustomProviderConfig( + id: widget.existing?.id ?? _id.text.trim(), + displayName: _name.text.trim(), + template: _template, + baseUrl: normalizeBaseUrl(_baseUrl.text), + modelId: _modelId.text.trim(), + )); + } + + @override + Widget build(BuildContext context) { + final colors = context.inkColors; + final typo = context.inkTypography; + final l10n = context.l10n; + + Widget field({ + required String label, + required TextEditingController controller, + required String errorKey, + bool enabled = true, + String? hint, + }) { + final err = _errors[errorKey]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: typo.caption.copyWith(color: colors.fg3)), + const SizedBox(height: InkSpacing.xs), + InkInput(controller: controller, enabled: enabled, hintText: hint), + if (err != null) ...[ + const SizedBox(height: InkSpacing.xs), + Text( + _errorText(context, err), + style: typo.caption.copyWith(color: colors.danger), + ), + ], + const SizedBox(height: InkSpacing.sm), + ], + ); + } + + return AlertDialog( + title: Text(widget.existing == null + ? l10n.settingsCustomProviderAddTitle + : l10n.settingsCustomProviderEditTitle), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + field( + label: l10n.settingsCustomProviderFieldId, + controller: _id, + errorKey: 'id', + enabled: widget.existing == null, + hint: 'my-openrouter', + ), + field( + label: l10n.settingsCustomProviderFieldDisplayName, + controller: _name, + errorKey: 'name', + ), + Text( + l10n.settingsCustomProviderFieldTemplate, + style: typo.caption.copyWith(color: colors.fg3), + ), + const SizedBox(height: InkSpacing.xs), + DropdownButton( + value: _template, + isExpanded: true, + items: [ + for (final t in kProviderProtocolTemplates.keys) + DropdownMenuItem(value: t, child: Text(t)), + ], + onChanged: (v) => + setState(() => _template = v ?? _template), + ), + const SizedBox(height: InkSpacing.sm), + field( + label: l10n.settingsCustomProviderFieldBaseUrl, + controller: _baseUrl, + errorKey: 'base_url', + hint: 'https://openrouter.ai/api/v1', + ), + field( + label: l10n.settingsCustomProviderFieldModelId, + controller: _modelId, + errorKey: 'model_id', + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: _save, + child: Text(l10n.settingsCustomProviderSave), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ef412dc2..4f9a40aa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -83,6 +83,34 @@ "settingsApiKeyRejected": "The provider rejected this key. It was not saved.", "settingsApiKeyCleared": "Cleared", + "settingsCustomProvidersSection": "Custom Providers", + "settingsCustomProvidersHint": "OpenAI-compatible endpoints, stored in custom_providers.json. API keys stay in secure storage. Changes take effect after restart.", + "settingsCustomProvidersEmpty": "No custom providers yet", + "settingsCustomProvidersAdd": "Add provider", + "settingsCustomProvidersRestartNotice": "Changes saved — restart InkFrame to apply", + "settingsCustomProviderAddTitle": "Add custom provider", + "settingsCustomProviderEditTitle": "Edit custom provider", + "settingsCustomProviderFieldId": "ID", + "settingsCustomProviderFieldDisplayName": "Display name", + "settingsCustomProviderFieldTemplate": "Template", + "settingsCustomProviderFieldBaseUrl": "Base URL", + "settingsCustomProviderFieldModelId": "Model ID", + "settingsCustomProviderSave": "Save", + "settingsCustomProviderErrorRequired": "Required", + "settingsCustomProviderErrorInvalidId": "Letters, digits, - and _ only; must start with a letter or digit", + "settingsCustomProviderErrorDuplicateId": "This ID is already in use", + "settingsCustomProviderErrorReservedId": "Conflicts with a built-in provider", + "settingsCustomProviderErrorInvalidBaseUrl": "Absolute http(s) URL without query, fragment or credentials", + "settingsCustomProviderDeleteTitle": "Delete provider?", + "settingsCustomProviderDeleteBody": "\"{name}\" will be removed from the config file. Its API key in secure storage is not deleted.", + "@settingsCustomProviderDeleteBody": { + "placeholders": { + "name": { "type": "String" } + } + }, + "settingsCustomProviderDeleteConfirm": "Delete", + "settingsCustomProviderSaveFailed": "Couldn't save. If custom_providers.json is corrupted, fix or remove it and retry.", + "settingsThemeSection": "Theme", "settingsCanvasSection": "Canvas", "settingsCanvasEdgeColor": "Connection line color", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 996bcbe4..1341feb0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -75,6 +75,34 @@ "settingsApiKeyRejected": "服务商拒绝了该 Key,未保存。", "settingsApiKeyCleared": "已清除", + "settingsCustomProvidersSection": "自定义服务商", + "settingsCustomProvidersHint": "OpenAI 兼容端点,存于 custom_providers.json;API Key 仍在安全存储。更改重启后生效。", + "settingsCustomProvidersEmpty": "暂无自定义服务商", + "settingsCustomProvidersAdd": "添加服务商", + "settingsCustomProvidersRestartNotice": "更改已保存——重启 InkFrame 后生效", + "settingsCustomProviderAddTitle": "添加自定义服务商", + "settingsCustomProviderEditTitle": "编辑自定义服务商", + "settingsCustomProviderFieldId": "ID", + "settingsCustomProviderFieldDisplayName": "显示名", + "settingsCustomProviderFieldTemplate": "协议模板", + "settingsCustomProviderFieldBaseUrl": "Base URL", + "settingsCustomProviderFieldModelId": "模型 ID", + "settingsCustomProviderSave": "保存", + "settingsCustomProviderErrorRequired": "必填", + "settingsCustomProviderErrorInvalidId": "仅限字母、数字、- 与 _,且以字母或数字开头", + "settingsCustomProviderErrorDuplicateId": "该 ID 已被占用", + "settingsCustomProviderErrorReservedId": "与内置服务商冲突", + "settingsCustomProviderErrorInvalidBaseUrl": "须为绝对 http(s) 地址,不含 query、fragment 或账号信息", + "settingsCustomProviderDeleteTitle": "删除服务商?", + "settingsCustomProviderDeleteBody": "「{name}」将从配置文件移除;安全存储中的 API Key 不会删除。", + "@settingsCustomProviderDeleteBody": { + "placeholders": { + "name": { "type": "String" } + } + }, + "settingsCustomProviderDeleteConfirm": "删除", + "settingsCustomProviderSaveFailed": "保存失败。若 custom_providers.json 已损坏,请修复或删除后重试。", + "settingsThemeSection": "主题", "settingsCanvasSection": "画布外观", "settingsCanvasEdgeColor": "连线颜色", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 498b7e28..f6932c04 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -476,6 +476,138 @@ abstract class AppLocalizations { /// **'Cleared'** String get settingsApiKeyCleared; + /// No description provided for @settingsCustomProvidersSection. + /// + /// In en, this message translates to: + /// **'Custom Providers'** + String get settingsCustomProvidersSection; + + /// No description provided for @settingsCustomProvidersHint. + /// + /// In en, this message translates to: + /// **'OpenAI-compatible endpoints, stored in custom_providers.json. API keys stay in secure storage. Changes take effect after restart.'** + String get settingsCustomProvidersHint; + + /// No description provided for @settingsCustomProvidersEmpty. + /// + /// In en, this message translates to: + /// **'No custom providers yet'** + String get settingsCustomProvidersEmpty; + + /// No description provided for @settingsCustomProvidersAdd. + /// + /// In en, this message translates to: + /// **'Add provider'** + String get settingsCustomProvidersAdd; + + /// No description provided for @settingsCustomProvidersRestartNotice. + /// + /// In en, this message translates to: + /// **'Changes saved — restart InkFrame to apply'** + String get settingsCustomProvidersRestartNotice; + + /// No description provided for @settingsCustomProviderAddTitle. + /// + /// In en, this message translates to: + /// **'Add custom provider'** + String get settingsCustomProviderAddTitle; + + /// No description provided for @settingsCustomProviderEditTitle. + /// + /// In en, this message translates to: + /// **'Edit custom provider'** + String get settingsCustomProviderEditTitle; + + /// No description provided for @settingsCustomProviderFieldId. + /// + /// In en, this message translates to: + /// **'ID'** + String get settingsCustomProviderFieldId; + + /// No description provided for @settingsCustomProviderFieldDisplayName. + /// + /// In en, this message translates to: + /// **'Display name'** + String get settingsCustomProviderFieldDisplayName; + + /// No description provided for @settingsCustomProviderFieldTemplate. + /// + /// In en, this message translates to: + /// **'Template'** + String get settingsCustomProviderFieldTemplate; + + /// No description provided for @settingsCustomProviderFieldBaseUrl. + /// + /// In en, this message translates to: + /// **'Base URL'** + String get settingsCustomProviderFieldBaseUrl; + + /// No description provided for @settingsCustomProviderFieldModelId. + /// + /// In en, this message translates to: + /// **'Model ID'** + String get settingsCustomProviderFieldModelId; + + /// No description provided for @settingsCustomProviderSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get settingsCustomProviderSave; + + /// No description provided for @settingsCustomProviderErrorRequired. + /// + /// In en, this message translates to: + /// **'Required'** + String get settingsCustomProviderErrorRequired; + + /// No description provided for @settingsCustomProviderErrorInvalidId. + /// + /// In en, this message translates to: + /// **'Letters, digits, - and _ only; must start with a letter or digit'** + String get settingsCustomProviderErrorInvalidId; + + /// No description provided for @settingsCustomProviderErrorDuplicateId. + /// + /// In en, this message translates to: + /// **'This ID is already in use'** + String get settingsCustomProviderErrorDuplicateId; + + /// No description provided for @settingsCustomProviderErrorReservedId. + /// + /// In en, this message translates to: + /// **'Conflicts with a built-in provider'** + String get settingsCustomProviderErrorReservedId; + + /// No description provided for @settingsCustomProviderErrorInvalidBaseUrl. + /// + /// In en, this message translates to: + /// **'Absolute http(s) URL without query, fragment or credentials'** + String get settingsCustomProviderErrorInvalidBaseUrl; + + /// No description provided for @settingsCustomProviderDeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete provider?'** + String get settingsCustomProviderDeleteTitle; + + /// No description provided for @settingsCustomProviderDeleteBody. + /// + /// In en, this message translates to: + /// **'\"{name}\" will be removed from the config file. Its API key in secure storage is not deleted.'** + String settingsCustomProviderDeleteBody(String name); + + /// No description provided for @settingsCustomProviderDeleteConfirm. + /// + /// In en, this message translates to: + /// **'Delete'** + String get settingsCustomProviderDeleteConfirm; + + /// No description provided for @settingsCustomProviderSaveFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t save. If custom_providers.json is corrupted, fix or remove it and retry.'** + String get settingsCustomProviderSaveFailed; + /// No description provided for @settingsThemeSection. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 2c3a339d..05301640 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -217,6 +217,81 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsApiKeyCleared => 'Cleared'; + @override + String get settingsCustomProvidersSection => 'Custom Providers'; + + @override + String get settingsCustomProvidersHint => + 'OpenAI-compatible endpoints, stored in custom_providers.json. API keys stay in secure storage. Changes take effect after restart.'; + + @override + String get settingsCustomProvidersEmpty => 'No custom providers yet'; + + @override + String get settingsCustomProvidersAdd => 'Add provider'; + + @override + String get settingsCustomProvidersRestartNotice => + 'Changes saved — restart InkFrame to apply'; + + @override + String get settingsCustomProviderAddTitle => 'Add custom provider'; + + @override + String get settingsCustomProviderEditTitle => 'Edit custom provider'; + + @override + String get settingsCustomProviderFieldId => 'ID'; + + @override + String get settingsCustomProviderFieldDisplayName => 'Display name'; + + @override + String get settingsCustomProviderFieldTemplate => 'Template'; + + @override + String get settingsCustomProviderFieldBaseUrl => 'Base URL'; + + @override + String get settingsCustomProviderFieldModelId => 'Model ID'; + + @override + String get settingsCustomProviderSave => 'Save'; + + @override + String get settingsCustomProviderErrorRequired => 'Required'; + + @override + String get settingsCustomProviderErrorInvalidId => + 'Letters, digits, - and _ only; must start with a letter or digit'; + + @override + String get settingsCustomProviderErrorDuplicateId => + 'This ID is already in use'; + + @override + String get settingsCustomProviderErrorReservedId => + 'Conflicts with a built-in provider'; + + @override + String get settingsCustomProviderErrorInvalidBaseUrl => + 'Absolute http(s) URL without query, fragment or credentials'; + + @override + String get settingsCustomProviderDeleteTitle => 'Delete provider?'; + + @override + String settingsCustomProviderDeleteBody(String name) { + return '\"$name\" will be removed from the config file. Its API key in secure storage is not deleted.'; + } + + @override + String get settingsCustomProviderDeleteConfirm => 'Delete'; + + @override + String get settingsCustomProviderSaveFailed => + 'Couldn\'t save. If custom_providers.json is corrupted, fix or remove it and retry.'; + @override String get settingsThemeSection => 'Theme'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index caaa5c34..d9d6a599 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -202,6 +202,77 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsApiKeyCleared => '已清除'; + @override + String get settingsCustomProvidersSection => '自定义服务商'; + + @override + String get settingsCustomProvidersHint => + 'OpenAI 兼容端点,存于 custom_providers.json;API Key 仍在安全存储。更改重启后生效。'; + + @override + String get settingsCustomProvidersEmpty => '暂无自定义服务商'; + + @override + String get settingsCustomProvidersAdd => '添加服务商'; + + @override + String get settingsCustomProvidersRestartNotice => '更改已保存——重启 InkFrame 后生效'; + + @override + String get settingsCustomProviderAddTitle => '添加自定义服务商'; + + @override + String get settingsCustomProviderEditTitle => '编辑自定义服务商'; + + @override + String get settingsCustomProviderFieldId => 'ID'; + + @override + String get settingsCustomProviderFieldDisplayName => '显示名'; + + @override + String get settingsCustomProviderFieldTemplate => '协议模板'; + + @override + String get settingsCustomProviderFieldBaseUrl => 'Base URL'; + + @override + String get settingsCustomProviderFieldModelId => '模型 ID'; + + @override + String get settingsCustomProviderSave => '保存'; + + @override + String get settingsCustomProviderErrorRequired => '必填'; + + @override + String get settingsCustomProviderErrorInvalidId => '仅限字母、数字、- 与 _,且以字母或数字开头'; + + @override + String get settingsCustomProviderErrorDuplicateId => '该 ID 已被占用'; + + @override + String get settingsCustomProviderErrorReservedId => '与内置服务商冲突'; + + @override + String get settingsCustomProviderErrorInvalidBaseUrl => + '须为绝对 http(s) 地址,不含 query、fragment 或账号信息'; + + @override + String get settingsCustomProviderDeleteTitle => '删除服务商?'; + + @override + String settingsCustomProviderDeleteBody(String name) { + return '「$name」将从配置文件移除;安全存储中的 API Key 不会删除。'; + } + + @override + String get settingsCustomProviderDeleteConfirm => '删除'; + + @override + String get settingsCustomProviderSaveFailed => + '保存失败。若 custom_providers.json 已损坏,请修复或删除后重试。'; + @override String get settingsThemeSection => '主题'; diff --git a/lib/main.dart b/lib/main.dart index e4aa570b..c946ef97 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -188,6 +188,7 @@ void main() { loggerProvider.overrideWithValue(fileLogger), crashReporterProvider.overrideWithValue(crashReporter), customProviderSourceProvider.overrideWithValue(customProviders), + customProviderStoreProvider.overrideWithValue(customProviders), ], ); final AppTeardown teardown = AppTeardown(); diff --git a/lib/services/custom_providers_file_service.dart b/lib/services/custom_providers_file_service.dart index 44dee43b..19d90e97 100644 --- a/lib/services/custom_providers_file_service.dart +++ b/lib/services/custom_providers_file_service.dart @@ -9,10 +9,12 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import '../core/errors/ink_error.dart'; import '../core/interfaces/custom_provider_source.dart'; +import '../core/interfaces/custom_provider_store.dart'; import '../core/logging/logger_service.dart'; import '../core/models/custom_provider_config.dart'; -import '../core/models/provider_protocol_template.dart'; +import '../core/models/custom_provider_validation.dart'; import '../core/paths/app_paths.dart'; /// 空实现:默认 DI 与单测用,不碰磁盘。 @@ -24,7 +26,12 @@ class EmptyCustomProviderSource implements CustomProviderSource { } /// 文件实现:`/config/custom_providers.json`。 -class CustomProvidersFileService implements CustomProviderSource { +/// +/// 写侧(GAP-1,CustomProviderStore):按 raw 条目操作保真——读侧剔除的 +/// 非法/未知条目原位保留;损坏文件拒写(LocalIOError)绝不覆盖; +/// `.partial`→rename 原子落盘,2 空格缩进保持可手编。写不触会话内快照。 +class CustomProvidersFileService + implements CustomProviderSource, CustomProviderStore { CustomProvidersFileService({ required AppPaths paths, required LoggerService logger, @@ -36,9 +43,6 @@ class CustomProvidersFileService implements CustomProviderSource { static const String _module = 'custom_providers'; static const String fileName = 'custom_providers.json'; - /// `id` 白名单模式——进 SecureStorage key / jobs.provider_id / 日志,收紧字符集。 - static final RegExp _idPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9_-]*$'); - final AppPaths _paths; final LoggerService _logger; final Set _reservedProviderIds; @@ -112,8 +116,10 @@ class CustomProvidersFileService implements CustomProviderSource { } } + // 各字段规则统一走 custom_provider_validation 纯函数(与设置页内联 + // 校验同一事实源);告警顺序与 seenIds 语义保持原样。 final id = field('id')!; - if (!_idPattern.hasMatch(id)) { + if (kCustomProviderIdPattern.hasMatch(id) == false) { _warnEntry(index, 'invalid_id', id: id); return null; } @@ -123,26 +129,19 @@ class CustomProvidersFileService implements CustomProviderSource { } final template = field('template')!; - if (!kProviderProtocolTemplates.containsKey(template)) { + if (validateTemplate(template) != null) { _warnEntry(index, 'unknown_template', id: id, extra: { 'template': template, }); return null; } - final baseUri = Uri.tryParse(field('base_url')!); - // 拒 query/fragment/userinfo:Dio baseUrl 与 path 是字符串拼接, - // 带 query 的 base_url 会把请求路径吞进 query 值,端点必然 404。 - if (baseUri == null || - !baseUri.isAbsolute || - (baseUri.scheme != 'http' && baseUri.scheme != 'https') || - baseUri.host.isEmpty || - baseUri.hasQuery || - baseUri.hasFragment || - baseUri.userInfo.isNotEmpty) { + final rawUrl = field('base_url')!; + if (validateBaseUrl(rawUrl) != null) { _warnEntry(index, 'invalid_base_url', id: id); return null; } + final baseUri = Uri.parse(rawUrl); final config = CustomProviderConfig( id: id, @@ -160,12 +159,113 @@ class CustomProvidersFileService implements CustomProviderSource { return config; } - static String _stripTrailingSlash(String url) { - var out = url; - while (out.endsWith('/')) { - out = out.substring(0, out.length - 1); + static String _stripTrailingSlash(String url) => normalizeBaseUrl(url); + + // ── 写侧(CustomProviderStore)──────────────────────────────────────── + + @override + Future> list() async { + final raw = await _readRawForEdit(missingOk: true); + final seenIds = {}; + final parsed = []; + for (var i = 0; i < raw.length; i++) { + final config = _parseEntry(raw[i], i, seenIds); + if (config != null) parsed.add(config); + } + return List.unmodifiable(parsed); + } + + @override + Future upsert(CustomProviderConfig config) async { + final raw = await _readRawForEdit(missingOk: true); + final entry = { + 'id': config.id, + 'display_name': config.displayName, + 'template': config.template, + 'base_url': config.baseUrl, + 'model_id': config.modelId, + }; + final idx = _indexOfId(raw, config.id); + if (idx >= 0) { + // 被编辑条目自身也保真(#200 评审 P2-1):以旧 raw map 为基底合并, + // 用户手编的未知字段(如 "_note")不随 UI 编辑丢失。 + final old = raw[idx]; + raw[idx] = { + if (old is Map) ...old.cast(), + ...entry, + }; + } else { + raw.add(entry); + } + await _writeAtomic(raw); + } + + @override + Future remove(String id) async { + final raw = await _readRawForEdit(missingOk: true); + final idx = _indexOfId(raw, id); + if (idx < 0) return; + raw.removeAt(idx); + await _writeAtomic(raw); + } + + static int _indexOfId(List raw, String id) { + for (var i = 0; i < raw.length; i++) { + final e = raw[i]; + if (e is Map && e['id'] == id) return i; + } + return -1; + } + + /// 编辑用 raw 读取:文件缺失=空数组;损坏/顶层非数组=拒写抛 LocalIOError + /// (读侧兜底是「app 能启动」,写侧兜底是「不销毁用户数据」——语义相反)。 + Future> _readRawForEdit({required bool missingOk}) async { + final f = _file; + final String raw; + try { + if (!await f.exists()) { + if (missingOk) return []; + throw const LocalIOError(extra: {'op': 'custom_providers.read'}); + } + raw = await f.readAsString(); + } on FileSystemException { + throw const LocalIOError(extra: {'op': 'custom_providers.read'}); + } + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + throw const LocalIOError( + extra: {'op': 'custom_providers.read', 'reason': 'corrupted'}, + ); + } + if (decoded is! List) { + throw const LocalIOError( + extra: {'op': 'custom_providers.read', 'reason': 'not_an_array'}, + ); + } + return List.of(decoded); + } + + Future _writeAtomic(List raw) async { + final f = _file; + final tmp = File('${f.path}.partial'); + try { + await f.parent.create(recursive: true); + await tmp.writeAsString( + const JsonEncoder.withIndent(' ').convert(raw), + flush: true, + ); + await tmp.rename(f.path); + } on FileSystemException { + // 放行点:清理残留 .partial 后以 InkError 语义上抛。 + try { + if (tmp.existsSync()) tmp.deleteSync(); + } on FileSystemException { + // 清理失败不掩盖主错误。 + } + throw const LocalIOError(extra: {'op': 'custom_providers.write'}); } - return out; } void _warnFile(String msg, String detail) { diff --git a/test/app/app_routing_test.dart b/test/app/app_routing_test.dart index 79527829..0dc1c8b2 100644 --- a/test/app/app_routing_test.dart +++ b/test/app/app_routing_test.dart @@ -19,8 +19,11 @@ import 'package:inkframe/services/file_preferences_service.dart'; import 'package:inkframe/features/canvas/providers/current_canvas_id.dart'; import 'package:inkframe/features/settings/settings_screen.dart'; import 'package:inkframe/features/studio/models/project_with_canvases.dart'; +import 'package:inkframe/core/di/custom_providers.dart'; import 'package:inkframe/core/di/repositories.dart'; import 'package:inkframe/core/di/video_export.dart'; +import 'package:inkframe/core/interfaces/custom_provider_store.dart'; +import 'package:inkframe/core/models/custom_provider_config.dart'; import 'package:inkframe/services/ffmpeg_locator.dart'; import 'package:inkframe/features/gallery/providers/current_gallery_project.dart'; import 'package:inkframe/features/gallery/widgets/gallery_screen.dart'; @@ -59,6 +62,17 @@ class _FakeFfmpegLocator implements FfmpegLocator { void invalidate() {} } +/// 恒空的自定义服务商 store(GAP-1 区在 SettingsScreen 内)。 +class _EmptyStore implements CustomProviderStore { + const _EmptyStore(); + @override + Future> list() async => const []; + @override + Future upsert(CustomProviderConfig config) async {} + @override + Future remove(String id) async {} +} + void main() { testWidgets('unlocked + studio screen → 渲染 StudioHomeScreen', (tester) async { await tester.binding.setSurfaceSize(const Size(1440, 900)); @@ -105,6 +119,9 @@ void main() { currentCanvasIdProvider.overrideWith((_) => null), // 密封 ON-3 ffmpeg 探测:不真 spawn `ffmpeg -version`。 ffmpegLocatorProvider.overrideWithValue(_FakeFfmpegLocator()), + // 密封 GAP-1 自定义服务商编辑区:默认 store 抛 UnimplementedError。 + customProviderStoreProvider + .overrideWithValue(const _EmptyStore()), // 密封 LB-13 孤儿回收启动读:boot 测试不触发真 PG/dart:io(否则 coverage 收集永挂)。 orphanReapStartupProvider.overrideWith((_) async {}), _sealDbReady(), diff --git a/test/app/empty_states_golden_test.dart b/test/app/empty_states_golden_test.dart index ccd4b677..e8aef525 100644 --- a/test/app/empty_states_golden_test.dart +++ b/test/app/empty_states_golden_test.dart @@ -18,7 +18,10 @@ import 'package:inkframe/core/di/paths.dart'; import 'package:inkframe/core/di/preferences.dart'; import 'package:inkframe/core/di/repositories.dart'; import 'package:inkframe/core/di/secure_storage.dart'; +import 'package:inkframe/core/di/custom_providers.dart'; import 'package:inkframe/core/di/database_backup.dart'; +import 'package:inkframe/core/interfaces/custom_provider_store.dart'; +import 'package:inkframe/core/models/custom_provider_config.dart'; import 'package:inkframe/core/di/video_export.dart'; import 'package:inkframe/core/errors/ink_error.dart'; import 'package:inkframe/core/interfaces/database_backup_service.dart'; @@ -52,6 +55,16 @@ class _FakeFfmpegLocator implements FfmpegLocator { void invalidate() {} } +class _EmptyStore implements CustomProviderStore { + const _EmptyStore(); + @override + Future> list() async => const []; + @override + Future upsert(CustomProviderConfig config) async {} + @override + Future remove(String id) async {} +} + class _EmptyBackupService implements DatabaseBackupService { @override Future backup(BackupConnection conn) async => @@ -157,6 +170,8 @@ void main() { ffmpegLocatorProvider.overrideWithValue(_FakeFfmpegLocator()), databaseBackupServiceProvider .overrideWithValue(_EmptyBackupService()), + // GAP-1 自定义服务商区:空 store 密封(空态入基线)。 + customProviderStoreProvider.overrideWithValue(const _EmptyStore()), // 版本行在折叠线下不进像素,仍显式钉死——消掉「靠插件缺失出 '—'」的 // 隐式依赖,防未来折叠线移动引入漂移。 packageInfoProvider.overrideWith( diff --git a/test/app/goldens/settings_screen.png b/test/app/goldens/settings_screen.png index 1105c3d2..00ed8d6e 100644 Binary files a/test/app/goldens/settings_screen.png and b/test/app/goldens/settings_screen.png differ diff --git a/test/core/models/custom_provider_validation_test.dart b/test/core/models/custom_provider_validation_test.dart new file mode 100644 index 00000000..b113cd33 --- /dev/null +++ b/test/core/models/custom_provider_validation_test.dart @@ -0,0 +1,96 @@ +// custom provider 校验纯函数全分支单测(GAP-1)。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/models/custom_provider_validation.dart'; + +void main() { + group('validateRequired', () { + test('空/纯空白 → emptyField;非空 → null', () { + expect(validateRequired(''), CustomProviderFieldError.emptyField); + expect(validateRequired(' '), CustomProviderFieldError.emptyField); + expect(validateRequired('x'), isNull); + }); + }); + + group('validateId', () { + const noReserved = {}; + test('合法 id 通过', () { + expect( + validateId('my-provider_1', + takenIds: const {}, reservedProviderIds: noReserved), + isNull, + ); + }); + test('空 → emptyField;非法字符/首字符 → invalidId', () { + expect( + validateId('', takenIds: const {}, reservedProviderIds: noReserved), + CustomProviderFieldError.emptyField, + ); + expect( + validateId('-lead', takenIds: const {}, reservedProviderIds: noReserved), + CustomProviderFieldError.invalidId, + ); + expect( + validateId('has space', + takenIds: const {}, reservedProviderIds: noReserved), + CustomProviderFieldError.invalidId, + ); + expect( + validateId('中文', takenIds: const {}, reservedProviderIds: noReserved), + CustomProviderFieldError.invalidId, + ); + }); + test('重复 → duplicateId;撞内置 → reservedId', () { + expect( + validateId('dup', + takenIds: const {'dup'}, reservedProviderIds: noReserved), + CustomProviderFieldError.duplicateId, + ); + expect( + validateId('gemini', + takenIds: const {}, + reservedProviderIds: const {'custom:gemini'}), + CustomProviderFieldError.reservedId, + ); + }); + }); + + group('validateTemplate', () { + test('白名单内通过;未知 → unknownTemplate;空 → emptyField', () { + expect(validateTemplate('openai-image'), isNull); + expect( + validateTemplate('made-up'), + CustomProviderFieldError.unknownTemplate, + ); + expect(validateTemplate(''), CustomProviderFieldError.emptyField); + }); + }); + + group('validateBaseUrl', () { + test('合法 http(s) 通过', () { + expect(validateBaseUrl('https://api.example.com/v1'), isNull); + expect(validateBaseUrl('http://127.0.0.1:8080'), isNull); + }); + test('相对/非 http/无 host/query/fragment/userinfo → invalidBaseUrl', () { + for (final bad in [ + '/relative', + 'ftp://x.com', + 'https://', + 'https://x.com/v1?key=1', + 'https://x.com/v1#frag', + 'https://user:pw@x.com', + ]) { + expect( + validateBaseUrl(bad), + CustomProviderFieldError.invalidBaseUrl, + reason: bad, + ); + } + expect(validateBaseUrl(''), CustomProviderFieldError.emptyField); + }); + }); + + test('normalizeBaseUrl 剔尾部斜杠', () { + expect(normalizeBaseUrl('https://x.com/v1///'), 'https://x.com/v1'); + expect(normalizeBaseUrl(' https://x.com '), 'https://x.com'); + }); +} diff --git a/test/features/settings/api_keys_section_test.dart b/test/features/settings/api_keys_section_test.dart index 50799677..5836bd00 100644 --- a/test/features/settings/api_keys_section_test.dart +++ b/test/features/settings/api_keys_section_test.dart @@ -163,4 +163,27 @@ void main() { expect(await secure.retrieve(storedKey), isNull); expect(find.text('Not set'), findsOneWidget); }); + + testWidgets('custom:* 行显示 displayName 而非裸 id(GAP-1 顺带修)', (tester) async { + const customId = 'custom:my-relay'; + final caps = fakeImageCapabilities(id: customId) + .copyWith(displayName: 'My Relay'); + final fake = FakeProvider(capabilities: caps); + await pumpInkApp( + tester, + const Scaffold(body: SingleChildScrollView(child: ApiKeysSection())), + overrides: [ + providerRegistryProvider.overrideWithValue( + CachingProviderRegistry({customId: () => fake}), + ), + providerCapabilitiesListProvider.overrideWithValue([caps]), + secureStorageServiceProvider.overrideWithValue(_FakeSecure()), + ], + ); + await tester.pumpAndSettle(); + + expect(find.text('My Relay'), findsOneWidget); + // 裸 id 只允许出现在成员副标题(多成员/名不同时显示),不做主标签 + expect(find.text(customId), findsOneWidget); // 副标题行 + }); } diff --git a/test/features/settings/custom_providers_section_test.dart b/test/features/settings/custom_providers_section_test.dart new file mode 100644 index 00000000..bf191df5 --- /dev/null +++ b/test/features/settings/custom_providers_section_test.dart @@ -0,0 +1,182 @@ +// CustomProvidersSection widget 测试(GAP-1):列表/添加/编辑/删除/内联校验/ +// 损坏文件错误呈现/重启提示。fake CustomProviderStore,零磁盘。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/di/custom_providers.dart'; +import 'package:inkframe/core/errors/ink_error.dart'; +import 'package:inkframe/core/interfaces/custom_provider_store.dart'; +import 'package:inkframe/core/models/custom_provider_config.dart'; +import 'package:inkframe/features/settings/widgets/custom_providers_section.dart'; + +import '../../_harness/test_app.dart'; + +CustomProviderConfig _cfg({ + String id = 'my-relay', + String displayName = 'My Relay', +}) => + CustomProviderConfig( + id: id, + displayName: displayName, + template: 'openai-image', + baseUrl: 'https://relay.example.com/v1', + modelId: 'flux-pro', + ); + +class _FakeStore implements CustomProviderStore { + _FakeStore([List? seed]) + : entries = [...?seed]; + final List entries; + bool broken = false; + + @override + Future> list() async { + if (broken) throw const LocalIOError(extra: {'reason': 'corrupted'}); + return List.unmodifiable(entries); + } + + @override + Future upsert(CustomProviderConfig config) async { + if (broken) throw const LocalIOError(extra: {'reason': 'corrupted'}); + final i = entries.indexWhere((e) => e.id == config.id); + if (i >= 0) { + entries[i] = config; + } else { + entries.add(config); + } + } + + @override + Future remove(String id) async { + if (broken) throw const LocalIOError(extra: {'reason': 'corrupted'}); + entries.removeWhere((e) => e.id == id); + } +} + +Future _pump(WidgetTester tester, _FakeStore store) async { + await pumpInkApp( + tester, + const Scaffold(body: SingleChildScrollView(child: CustomProvidersSection())), + surfaceSize: const Size(900, 900), + overrides: [ + customProviderStoreProvider.overrideWithValue(store), + ], + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('空态 → empty 文案;有条目 → 行渲染 displayName + providerId', + (tester) async { + await _pump(tester, _FakeStore()); + expect(find.text('No custom providers yet'), findsOneWidget); + + await _pump(tester, _FakeStore([_cfg()])); + expect(find.text('My Relay'), findsOneWidget); + expect(find.textContaining('custom:my-relay'), findsOneWidget); + }); + + testWidgets('添加流:合法输入 → store.upsert + 列表刷新 + 重启提示', (tester) async { + final store = _FakeStore(); + await _pump(tester, store); + + await tester.tap(find.text('Add provider')); + await tester.pumpAndSettle(); + + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), 'new-relay'); + await tester.enterText(fields.at(1), 'New Relay'); + await tester.enterText(fields.at(2), 'https://api.example.com/v1/'); + await tester.enterText(fields.at(3), 'sdxl'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(store.entries.single.id, 'new-relay'); + // 尾斜杠已规范化 + expect(store.entries.single.baseUrl, 'https://api.example.com/v1'); + expect(find.text('New Relay'), findsOneWidget); + expect( + find.text('Changes saved — restart InkFrame to apply'), + findsOneWidget, + ); + }); + + testWidgets('内联校验:重复 id 与带 query 的 base_url 报错不提交', (tester) async { + final store = _FakeStore([_cfg()]); + await _pump(tester, store); + + await tester.tap(find.text('Add provider')); + await tester.pumpAndSettle(); + + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), 'my-relay'); // 撞现存 + await tester.enterText(fields.at(1), 'Dup'); + await tester.enterText(fields.at(2), 'https://x.com/v1?key=1'); + await tester.enterText(fields.at(3), 'm'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('This ID is already in use'), findsOneWidget); + expect( + find.text('Absolute http(s) URL without query, fragment or credentials'), + findsOneWidget, + ); + // 未提交:对话框仍开,store 未变 + expect(store.entries, hasLength(1)); + expect(find.text('Add custom provider'), findsOneWidget); + }); + + testWidgets('编辑流:id 字段锁定,保存走 upsert 原 id', (tester) async { + final store = _FakeStore([_cfg()]); + await _pump(tester, store); + + await tester.tap(find.byTooltip('Edit custom provider')); + await tester.pumpAndSettle(); + + final idField = tester.widget(find.byType(TextField).at(0)); + expect(idField.enabled, isFalse); + + await tester.enterText(find.byType(TextField).at(1), 'Renamed'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(store.entries.single.id, 'my-relay'); + expect(store.entries.single.displayName, 'Renamed'); + }); + + testWidgets('删除流:确认对话框 → remove;取消不动', (tester) async { + final store = _FakeStore([_cfg()]); + await _pump(tester, store); + + await tester.tap(find.byTooltip('Delete provider?')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(store.entries, hasLength(1)); + + await tester.tap(find.byTooltip('Delete provider?')); + await tester.pumpAndSettle(); + expect(find.textContaining('My Relay'), findsWidgets); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(store.entries, isEmpty); + }); + + testWidgets('损坏文件:列表 error 呈现不崩;写拒绝 → snackbar', (tester) async { + final store = _FakeStore()..broken = true; + await _pump(tester, store); + + // 列表 error 态(LocalIOError → l10n 文案) + expect( + find.text('Local disk I/O error. Check space and permissions.'), + findsOneWidget, + ); + + // 添加入口仍可点;list() 拒绝 → snackbar 提示 + await tester.tap(find.text('Add provider')); + await tester.pumpAndSettle(); + expect( + find.textContaining("Couldn't save"), + findsOneWidget, + ); + }); +} diff --git a/test/services/custom_providers_file_service_test.dart b/test/services/custom_providers_file_service_test.dart index 84c567d1..ab008232 100644 --- a/test/services/custom_providers_file_service_test.dart +++ b/test/services/custom_providers_file_service_test.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/core/logging/logger_service.dart'; +import 'package:inkframe/core/errors/ink_error.dart'; +import 'package:inkframe/core/models/custom_provider_config.dart'; import 'package:inkframe/core/paths/app_paths.dart'; import 'package:inkframe/services/custom_providers_file_service.dart'; @@ -173,4 +175,118 @@ void main() { await svc.load(); expect(svc.configs, isEmpty); }); + + group('写侧 CustomProviderStore(GAP-1)', () { + CustomProviderConfig cfg({ + String id = 'my-endpoint', + String displayName = 'My Endpoint', + String baseUrl = 'https://example.com/v1', + String modelId = 'flux-pro', + }) => + CustomProviderConfig( + id: id, + displayName: displayName, + template: 'openai-image', + baseUrl: baseUrl, + modelId: modelId, + ); + + List rawFile() => jsonDecode( + File('${paths.config.path}/${CustomProvidersFileService.fileName}') + .readAsStringSync(), + ) as List; + + test('文件缺失时 upsert → 创建文件含单条;fresh list 可读回', () async { + final svc = build(); + await svc.upsert(cfg()); + expect(rawFile(), hasLength(1)); + final listed = await svc.list(); + expect(listed.single.id, 'my-endpoint'); + // 会话内快照不受写影响(重启生效边界) + expect(svc.configs, isEmpty); + }); + + test('upsert 同 id 原位替换,不同 id 追加末尾', () async { + writeConfig([entry(), entry(id: 'other')]); + final svc = build(); + await svc.upsert(cfg(displayName: 'Renamed')); + var raw = rawFile(); + expect(raw, hasLength(2)); + expect((raw[0]! as Map)['display_name'], 'Renamed'); + + await svc.upsert(cfg(id: 'third')); + raw = rawFile(); + expect(raw, hasLength(3)); + expect((raw[2]! as Map)['id'], 'third'); + }); + + test('保真:被编辑条目自身的未知字段合并保留(评审 P2-1)', () async { + writeConfig([ + {...entry(), '_note': '公司中转'}, + ]); + final svc = build(); + await svc.upsert(cfg(displayName: 'Edited')); + final raw = rawFile(); + final map = raw.single! as Map; + expect(map['_note'], '公司中转', reason: '手编字段不随 UI 编辑丢失'); + expect(map['display_name'], 'Edited'); + }); + + test('保真:非法 raw 条目在写后原位保留', () async { + writeConfig([ + {'garbage': true}, + entry(), + ]); + final svc = build(); + await svc.upsert(cfg(displayName: 'Edited')); + final raw = rawFile(); + expect(raw, hasLength(2)); + expect((raw[0]! as Map)['garbage'], true, reason: '坏条目不得被销毁'); + expect((raw[1]! as Map)['display_name'], 'Edited'); + }); + + test('remove 按 id 删除;无匹配 no-op;坏条目保留', () async { + writeConfig([ + {'garbage': true}, + entry(), + ]); + final svc = build(); + await svc.remove('my-endpoint'); + expect(rawFile(), hasLength(1)); + await svc.remove('ghost'); + expect(rawFile(), hasLength(1)); + }); + + test('损坏文件 → 写操作拒绝抛 LocalIOError,文件原样保留', () async { + writeConfig('{broken'); + final svc = build(); + await expectLater(svc.upsert(cfg()), throwsA(isA())); + await expectLater(svc.remove('x'), throwsA(isA())); + expect( + File('${paths.config.path}/${CustomProvidersFileService.fileName}') + .readAsStringSync(), + '{broken', + reason: '绝不覆盖用户可能想抢救的文件', + ); + }); + + test('顶层非数组 → 同样拒写', () async { + writeConfig('{}'); + final svc = build(); + await expectLater(svc.upsert(cfg()), throwsA(isA())); + }); + + test('原子写:无 .partial 残留;输出 pretty 可手编', () async { + final svc = build(); + await svc.upsert(cfg()); + final leftovers = paths.config + .listSync() + .where((f) => f.path.contains('.partial')); + expect(leftovers, isEmpty); + final text = + File('${paths.config.path}/${CustomProvidersFileService.fileName}') + .readAsStringSync(); + expect(text, contains('\n'), reason: 'pretty 格式保持可手编'); + }); + }); }