From 0f667cf6e12646c513f24afcdd9daad82b9c3855 Mon Sep 17 00:00:00 2001 From: Kerro Date: Sat, 18 Jul 2026 02:30:16 +1000 Subject: [PATCH 1/6] =?UTF-8?q?feat(providers):=20GAP-1=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E7=BA=AF=E5=87=BD=E6=95=B0=20+=20custom=20provider=20?= =?UTF-8?q?=E5=86=99=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomProviderStore(list/upsert/remove):raw 条目保真——非法条目原位保留; 损坏文件拒写 LocalIOError 绝不覆盖;.partial→rename 原子写 pretty JSON; 写不触会话内快照(重启生效边界)。字段校验抽 core 纯函数,服务解析与 设置页内联共用同一事实源;_parseEntry 改用共享校验(告警语义不变)。 Co-Authored-By: Claude Fable 5 --- .../2026-07-18-gap-1-custom-providers-ui.md | 65 +++++++++ lib/core/di/custom_providers.dart | 10 ++ .../interfaces/custom_provider_store.dart | 21 +++ .../models/custom_provider_validation.dart | 78 ++++++++++ lib/main.dart | 1 + .../custom_providers_file_service.dart | 138 +++++++++++++++--- .../custom_provider_validation_test.dart | 96 ++++++++++++ .../custom_providers_file_service_test.dart | 104 +++++++++++++ 8 files changed, 491 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-gap-1-custom-providers-ui.md create mode 100644 lib/core/interfaces/custom_provider_store.dart create mode 100644 lib/core/models/custom_provider_validation.dart create mode 100644 test/core/models/custom_provider_validation_test.dart 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/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..e4559b8f 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,107 @@ 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) { + raw[idx] = 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/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/services/custom_providers_file_service_test.dart b/test/services/custom_providers_file_service_test.dart index 84c567d1..5cc423c9 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,106 @@ 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('保真:非法 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 格式保持可手编'); + }); + }); } From 535378c99e74e1ba72b9159f0ba87e790ccc012c Mon Sep 17 00:00:00 2001 From: Kerro Date: Sat, 18 Jul 2026 02:38:56 +1000 Subject: [PATCH 2/6] =?UTF-8?q?feat(settings):=20GAP-1=20Custom=20Provider?= =?UTF-8?q?s=20=E7=BC=96=E8=BE=91=E5=8C=BA=20+=20API=20Keys=20displayName?= =?UTF-8?q?=20=E9=A1=BA=E5=B8=A6=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表+5 字段表单对话框(校验=共享纯函数,内联报错不提交)+删除确认; 写后常驻「重启后生效」条;损坏文件列表呈现原因、写拒绝 snackbar。 ARB +23 键;api_keys custom:* 行显示 displayName;app_routing/golden 设置场景密封空 store。widget 测试 6 例 + api_keys 1 例。 Co-Authored-By: Claude Fable 5 --- lib/features/settings/settings_screen.dart | 3 + .../settings/widgets/api_keys_section.dart | 9 +- .../widgets/custom_providers_section.dart | 448 ++++++++++++++++++ lib/l10n/app_en.arb | 28 ++ lib/l10n/app_zh.arb | 28 ++ lib/l10n/generated/app_localizations.dart | 132 ++++++ lib/l10n/generated/app_localizations_en.dart | 75 +++ lib/l10n/generated/app_localizations_zh.dart | 71 +++ test/app/app_routing_test.dart | 17 + test/app/empty_states_golden_test.dart | 15 + .../settings/api_keys_section_test.dart | 23 + .../custom_providers_section_test.dart | 182 +++++++ 12 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 lib/features/settings/widgets/custom_providers_section.dart create mode 100644 test/features/settings/custom_providers_section_test.dart 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..228afc4b --- /dev/null +++ b/lib/features/settings/widgets/custom_providers_section.dart @@ -0,0 +1,448 @@ +// 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; + 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; + 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/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/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, + ); + }); +} From c7fd80a9e48d810525a2f011d2cfb99ab6191faa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Jul 2026 16:40:02 +0000 Subject: [PATCH 3/6] test(golden): regenerate baselines on ubuntu runner [skip ci] --- test/app/goldens/settings_screen.png | Bin 58068 -> 67593 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/app/goldens/settings_screen.png b/test/app/goldens/settings_screen.png index 1105c3d28ac971296fddaa2e764374e168bd5481..00ed8d6e839e37a424445bfac41900dcb0f31688 100644 GIT binary patch delta 36710 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 delta 27110 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 From a546d4ac321567779835531ea39fb74b25091c3f Mon Sep 17 00:00:00 2001 From: Kerro Date: Sat, 18 Jul 2026 02:40:44 +1000 Subject: [PATCH 4/6] ci: rerun after golden re-mint Co-Authored-By: Claude Fable 5 From 20fef19c7d19a42469eb2c629af2fee03f81b602 Mon Sep 17 00:00:00 2001 From: Kerro Date: Sat, 18 Jul 2026 02:41:33 +1000 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20BOARD/MASTERPLAN=20=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=20GAP-1(#200)=E2=80=94=E2=80=94=E4=B8=8A=E7=BA=BF?= =?UTF-8?q?=E5=89=8D=E5=BF=85=E5=81=9A=201-10=20=E9=80=90=E6=9D=A1?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/BOARD.md | 1 + docs/MASTERPLAN.md | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index 261e65dc..232391f1 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 补遗(审计发现的悬空项) 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 框选群拖、 From 3595b318ef49e737fd5e627c44c99b05cdfae6ff Mon Sep 17 00:00:00 2001 From: Kerro Date: Sat, 18 Jul 2026 02:53:04 +1000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E8=AF=84=E5=AE=A1=20P2=C3=972?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=A2=AB=E7=BC=96=E8=BE=91=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E6=9C=AA=E7=9F=A5=E5=AD=97=E6=AE=B5=E5=90=88=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F=20+=20=E6=88=90=E5=8A=9F=E8=B7=AF=E5=BE=84=20mounted?= =?UTF-8?q?=20=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1:upsert 原位替换改以旧 raw map 为基底合并——手编未知字段(_note 等) 不随 UI 编辑丢失,补保真测试。P2-2:写成功路径 invalidate 前补 context.mounted (失败路径已有,不对称;unmount 后 autoDispose 天然重建)。P3×5 记 BOARD 债。 Co-Authored-By: Claude Fable 5 --- docs/BOARD.md | 1 + .../settings/widgets/custom_providers_section.dart | 5 +++-- lib/services/custom_providers_file_service.dart | 8 +++++++- .../services/custom_providers_file_service_test.dart | 12 ++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/BOARD.md b/docs/BOARD.md index 232391f1..922612a2 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -154,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/lib/features/settings/widgets/custom_providers_section.dart b/lib/features/settings/widgets/custom_providers_section.dart index 228afc4b..806b341a 100644 --- a/lib/features/settings/widgets/custom_providers_section.dart +++ b/lib/features/settings/widgets/custom_providers_section.dart @@ -205,7 +205,8 @@ Future _confirmDelete( return; } dirty.state = true; - ref.invalidate(customProvidersListProvider); + // unmount 后 autoDispose 的 list provider 下次挂载天然重建,无需 invalidate。 + if (context.mounted) ref.invalidate(customProvidersListProvider); } Future _openEditor( @@ -253,7 +254,7 @@ Future _openEditor( return; } dirty.state = true; - ref.invalidate(customProvidersListProvider); + if (context.mounted) ref.invalidate(customProvidersListProvider); } /// 5 字段表单:Save 时全量校验,错误内联呈现,通过才 pop 结果。 diff --git a/lib/services/custom_providers_file_service.dart b/lib/services/custom_providers_file_service.dart index e4559b8f..19d90e97 100644 --- a/lib/services/custom_providers_file_service.dart +++ b/lib/services/custom_providers_file_service.dart @@ -187,7 +187,13 @@ class CustomProvidersFileService }; final idx = _indexOfId(raw, config.id); if (idx >= 0) { - raw[idx] = entry; + // 被编辑条目自身也保真(#200 评审 P2-1):以旧 raw map 为基底合并, + // 用户手编的未知字段(如 "_note")不随 UI 编辑丢失。 + final old = raw[idx]; + raw[idx] = { + if (old is Map) ...old.cast(), + ...entry, + }; } else { raw.add(entry); } diff --git a/test/services/custom_providers_file_service_test.dart b/test/services/custom_providers_file_service_test.dart index 5cc423c9..ab008232 100644 --- a/test/services/custom_providers_file_service_test.dart +++ b/test/services/custom_providers_file_service_test.dart @@ -220,6 +220,18 @@ void main() { 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},