Files
patbond-doc/docs/development/iterations/iteration-4/05-ai-create-ui-spec.md
T
lixi 79b33dba31 docs: M4「AI 创作」开工分析八份报告 + 汇总拍板页入档挂导航
八角色并行开工分析,合计 7448 行;另出 00 汇总页(跨角色收敛结论、
13 项待拍板、6 项待仲裁分歧、未取证项汇总),挂第四迭代导航最前。
mkdocs build --strict 通过。

基线实测修正(文档与实况不符):
- api 测试 381(releases.md 记 379,成因待仲裁)
- 埋点白名单 41(四份文档记 42,experiment_exposed 重复计数)
- 真机验证挂起 10 项(转述链 4→6→8→10 每跳丢项)
- E2E 断言机械可数 226(声称 234 无可复核来源)
- v0.4.0 实际发布 09-14 11:17;CI 非红,三仓五上下文全绿

多方独立收敛(无需拍板):
- 队列用 Postgres SKIP LOCKED + 租约列,不引入 Redis/MQ
- 服务端零对象写能力(ObjectStorage 无 put/get),M4 立足点缺地基
- 「四模块字节级快照锁 CI」不存在,实际门禁仅结构断言
- 定稿模型 input_asset_id NOT NULL,即图生图不支持文生图
- 跨 schema 外键补回是 V5 自身指令,裁剪理由已不成立

阻塞项与安全缺口:
- AI provider BLOCKED:零 SDK/endpoint/额度,正典种子即 fixture
- 分支保护必需上下文选错触发器:(push) 限定 branches:[dev],
  致「推 dev 即满足门禁」且「非 dev 分支 PR 永久无法合并」
- check-secrets.sh 对 sk-/sk-ant- 零覆盖,须先于任何 AI key 落地
- 北极星 09-21 窗口已于 09-13 关闭,补救无从下手,建议改事件驱动

本批核心教训:13 处文档/注释与代码相反且多已被下游采信,其中
5 处造成实际规模误判(widthPx M→S、数据模型早已定稿 L→M、
社区侧 purpose 校验实际不存在等)。汇总页 §0 立转述纪律。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:48:56 +08:00

1118 lines
115 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 05 · 第四迭代 AI 创作 UI 设计规范
> 作者:UI Designer
> 日期:2026-09-14
> 迭代:Iteration 4「M4 AI 创作」
> 性质:开工前设计规范;**只定规格,不改代码**(本次未触碰 `patbond-flutter` 任何文件、未改 `mkdocs.yml`、未 commit
> 素材来源(全部已开源码/原文核实,行号见各节):
> - 品牌正典 `AI宠物_iOS_UI设计稿.html`ADR-005),**其第三画框正是「AI 创作」**(:317-356
> - 落地 token `patbond-flutter/lib/core/theme/app_theme.dart`
> - 前序规范 `iteration-1/04`、`iteration-2/05`、`iteration-3/05`、`iteration-3.5/05`(本规范延续其写法与颗粒度)
> - 冻结契约 `patbond-doc/docs/api/openapi.yaml` v1.4.0
> - `creation` schema 设计稿 `patbond-doc/docs/database/patbond_postgresql.sql:556-716`**未进 Flyway、未实现,但状态机已定型**)
> - 现状 demo `patbond-flutter/lib/features/create/create_page.dart`563 行,M4 的替换目标)
## 结论先行
1. **界面 8 个单元**:6 页(A1 创作首页 / A2 参数页 / A3 等待页 / A4 结果页 / A5 我的 AI 作品 / A6 全屏查看)+ 1 sheetS1 配额说明)+ 1 全局层(G1 进行中指示位)。
2. **等待态推荐方案**:提交后自动进 A3,**明确允许离开**;进度用「阶段文字 + 不确定进度环」为主形态,`progress>0` 时才叠确定型数字;**不展示预计剩余时间,改展示已用时**;回来的路三条(G1 全局条 → Tab 角标 → A5 列表)。理由见 §2.3。
3. **组件账:新建 8,复用 16(其中 6 需小改)**。全局角标 / 全局悬浮层 / 跨页任务状态层在客户端**零先例**,是本迭代最大的一块新建(§4)。
4. **`widthPx`/`heightPx` 债在 M4 由隐性升为显性**,严重度**中高**,但修复成本极低(AI 输出路径**不需要图片解码**)。判断与三处修法见 §8.3。
5. **待拍板决策 18 项**(§9)。最关键三项:D1 等待态形态与「不承诺推送」的文案纪律、D2 参考图必填与否(**任务描述的「可选参考图」与设计稿 `input_asset_id NOT NULL` 冲突**)、D3 配额契约(429 与两个新错误码尚不存在)。
6. **两处必须先纠正的事实误解**,否则整条链路会照错误前提施工:见 §0.6。
---
## 0. 前置核实
> 纪律说明:M3.5 有过「一份报告的转述结论被全链路采信」的教训。本节所有「有 XX / 没有 XX」的判断都开过源码,注明文件与行号。凡未取证的,明写「未取证」。
### 0.1 设计 token 的唯一出处 —— `lib/core/theme/app_theme.dart`
本规范**不发明任何新色值、新圆角值**。全部取自该文件:
| token 类 | 定义位置 | 取值 |
| --- | --- | --- |
| `AppColors` | `app_theme.dart:5-67` | 17 个语义色 + `brandGradient`:62-66`primary → accent` topLeft→bottomRight |
| `AppRadius` | `app_theme.dart:70-85` | `sm 12` / `md 16` / `lg 18` / `xl 24` / `pill 999` |
| 字级 | `app_theme.dart:105-123` | `headlineSmall 22/w800``titleLarge 18/w800``titleMedium 15/w700``bodyMedium 14/h1.5``bodySmall 12/h1.4`**默认色 `muted`DEBT-2 重灾区** |
| 卡片 | `app_theme.dart:124-132` | 白底 + `border` 1px + 圆角 `xl` 24 + elevation 0 + margin 0 |
| 主按钮 | `app_theme.dart:133-145` | `primaryStrong` 底 + 白字 + 最小 64×52 + 圆角 `md` 16;禁用 `ink` 12% 底 + 38% 字 |
| 输入框 | `app_theme.dart:146-172` | 白底 + `border` 描边 + 圆角 `lg` 18 + padding 16/14;聚焦 `primary` 1.5px |
| 底栏 | `app_theme.dart:180-195` | `surface` 底 + `surfaceTint` 指示器 + height 72 + label 11 |
**间距没有 token 类**(已核实:`app_theme.dart` 中无 `AppSpacing`/`AppGaps` 之类)。全仓 padding 是散落硬编码(14 / 16 / 18 居多)。本规范沿用前三份规范的口头刻度 **4 / 8 / 12 / 16 / 24 / 32**,页面内边距 `EdgeInsets.fromLTRB(16, 12, 16, 28)`(与 `create_page.dart:134``post_compose_page.dart:540-541``home_page.dart:357-358` 一致)。
**正典色值与落地 token 逐一对齐**`AI宠物_iOS_UI设计稿.html:12-21`):`peach #FFE8D6`=`surfaceTint``coral #FF6F4C`=`primary``coral-dark #7A2E12`=`primaryDark``amber #FFB648`=`accent``amber-dark #7A4B0A`=`accentDark``ink #3E2A1F`=`ink``muted #9C8977`=`muted``border #F0DCC8`=`border``sage #7FA88A`=`success``sage-bg #E8F0E8`=`successSurface`。零漂移。
### 0.2 服务端能给什么、给不了什么(决定设计边界)
**确定不存在(v1.4.0 冻结态)**
| 事项 | 证据 |
| --- | --- |
| 任何 AI 创作端点、模型/风格目录端点 | `openapi.yaml` 32 条 path 全清单中零命中;`patbond-api/pom.xml:16-20` 只有 5 个模块,无 creation |
| 任何异步任务状态查询/轮询端点 | 同上;唯一定时器是 `SessionCleanupJob.java:32-35`(内部清理,无对外状态) |
| 429 / 配额 / 限流错误码 | `ErrorCode.java:10-38` 共 27 个业务码,无 429/`TOO_MANY`/`RATE_LIMIT`/`QUOTA``openapi.yaml:32-62` 错误码表同;`feature-checklist.md:194` 把 429 记为 ⬜ 未做 |
| AI 提供方决策 | 22 条 ADR`decisions.md:8-195`)无 AI 供应方;`development-plan.md:358` 明写「AI 提供方…**尚未确定**」 |
| `creation` schema 实际建表 | Flyway 止于 `V5__community_baseline.sql``decisions.md:192`「下一个版本号 V6 留给后续」 |
| 视频能力 | `openapi.yaml:3456-3457` `kind` enum 只有 `image`ADR-018 视频后置,`decisions.md:159-161` |
| WebSocket / SSE / 推送 | 无依赖、无端点 |
**确定存在、可直接复用**:两步上传闭环(`openapi.yaml:1223``:1256`);`media` schema 可承载 AI 输入输出(`development-plan.md:64`);`purpose` 白名单是**配置项而非 DB CHECK**`decisions.md:192`),新增 `ai_input`/`ai_output` **零迁移**`posts.category='ai_creation'``posts.generation_job_id` 裸列已在库(`V5__community_baseline.sql:48``:68`);游标分页正典 `{items, nextCursor, hasMore}``openapi.yaml:133-134`)。
**⚠ 写侧当前拒收 `ai_creation`**`CreatePostRequest.java:26``@Pattern(regexp = "general|help")`,提交即 400/40000。M4 必须放开(§10 诉求 R5)。
### 0.3 `creation.generation_jobs` 状态机(本规范等待态设计的唯一依据)
设计稿 `patbond_postgresql.sql:605-716`。**未实现、未冻结进契约**,但状态-字段联动是 CHECK 级铁律(`:667-691`),UI 可安全依赖:
| status | progress | 其他字段铁律 | UI 含义 |
| --- | --- | --- | --- |
| `queued` | **恒 0** | `started_at` / `completed_at` / `output_asset_id` / `error_code` 全 NULL | 排队中 |
| `running` | **0..100,无下限约束** | `started_at` 非空;`output_asset_id` / `error_code` NULL | 生成中 |
| `succeeded` | **恒 100** | `output_asset_id` **非空**`error_code` NULL、`completed_at` 非空 | 已完成 |
| `failed` | 无约束 | `error_code` **非空**`output_asset_id` NULL、`completed_at` 非空 | 失败 |
| `cancelled` | 无约束 | `completed_at` 非空;output/error 均无约束 | 已取消 |
其余可用字段:`attempt_count` / `max_attempts DEFAULT 3``:628-629`)、`error_code varchar(64)` / `error_message varchar(1000)``:631-632`)、`prompt varchar(10000)` / `negative_prompt varchar(3000)``:614-615`)、`width_px`/`height_px` 各 64..8192`:616-617``:649-653`)、`upscale``:619`)、`pet_id` nullable`:611`)、`input_asset_id` **NOT NULL**`:612`)、`output_asset_id` **单列 nullable**`:613`)。
**从这张表直接推出的三条设计约束**(详论见 §2.3):
1. `queued` 时 progress 恒 0 → **排队态绝不能画确定型进度条**,否则用户看到 0% 不动会判定卡死。
2. `running` 时 progress **无下限**(提供方可能不上报,一直是 0)→ UI 必须能画不确定型,**确定型只作为可选增强**。
3. `output_asset_id` 是**单列** → **M4 结果恒单图**。多图需要 job→N assets 的关系表,设计稿没有。A4 按单图设计,布局为多图预留但不实现(§2.4)。
### 0.4 正典「AI 创作」画框已给出的语言(本规范全部延续)
`AI宠物_iOS_UI设计稿.html:317-356` + CSS `:150-173`
| 正典元素 | 描述(CSS 行号) | 本规范处置 |
| --- | --- | --- |
| header「AI 创作」+ 右上 `🕘` icon-btn | :323-326 | **保留**,🕘 即历史入口 → A5「我的 AI 作品」(正典早已给出这个入口位,不是新增提案) |
| `.ai-hero` | :151-156`coral→amber` 135° 渐变、圆角 20、padding 18、**白字**、h3 16、p 11 opacity .92 | 形态保留,**承字色必须改**(白字于该渐变仅 2.22:1,§7.2 D9 |
| `.upload-btn` | :157-161,白 92% 底 + `coral-dark` 字 12/w600、圆角 12、padding 9 | **保留**primaryDark 于白 92% 叠渐变最不利端 8.69:1,达标) |
| `.section-title` | :16213/w600 `ink` | 收敛为既有 `titleLarge` 18/w800(与全 app 分区标题一致,正典 13 偏小) |
| `.style-grid` / `.style-card` | :163-173**3 列网格** gap 10、圆角 14、`aspect-ratio 1/1.1``peach` 底、border 1px、底部 `rgba(62,42,31,.75)` 渐变 + 白字 9/w600 居中 | **采纳 3 列网格**(现 `create_page.dart:218-292` 是横滑 125 宽列表,与正典不符,§2.1 修订);圆角 14 收敛为 `sm` 12scrim 75% 提到 80%(§7.2 |
| 「热门风格」+「AI 视频」两个分组 | :332-346 | **数据驱动**:目录端点返回哪些 `media_kind` 就渲染哪些分组;M4 只有 image 时**不渲染 kind 切换器**(§2.1、D6 |
| 「我的」页菜单「🖼 我的 AI 作品」 | :497 | A5 的第二个入口 |
| 会员卡「AI 会员 Plus / 无限 AI 生图」+ PRO 徽章 | :489-493 | **M4 不渲染**(无支付能力,点了是死路,§2.7 / D12) |
### 0.5 客户端零先例的三件事(本迭代最大的新建量)
逐项 grep 已核实:
1. **全局角标 / 全局悬浮层 / 全局 banner —— 零先例。** `Badge`Material)全 lib 零使用(唯一 `Badge` 相关命中是 `pet_avatar.dart``showEditBadge`/`_EditBadge`,那是头像编辑徽标不是计数角标);`FloatingActionButton` 零使用;`Overlay`/`OverlayEntry` 零使用;`MaterialBanner` 零使用(仅两个自绘**页内**横幅 `InlineErrorBanner``_RestoredDraftBanner`)。G1 要在 `MainShellPage.build` 的 Scaffold 层新开插槽。
2. **跨页 / 跨进程的任务状态层 —— 零先例。** 全 lib 只有 3 个 Timer,无一是业务轮询(`analytics_service.dart:195` 埋点 flush、`feed_exposure.dart:48` 曝光停留、`splash_page.dart:49` 转圈延迟);`poll` 零业务命中。持久化只有 `SharedPreferences`(宠物 + 地区天气 + 埋点队列)与 `FlutterSecureStorage`(仅 token);无 Hive/sqflite/drift。`MediaUploader` 的纪律相反:凭据只存内存、离页 `reset()+dispose()`、杀进程即全丢。
3. **月份网格选择器 —— 零实现**(§8.2)。
### 0.6 ⚠ 两处必须先纠正的事实误解
**(a)「可选参考图」与设计稿冲突。** 工单描述写「可选参考图(复用 M3 媒体上传)」,但 `patbond_postgresql.sql:612``input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT` —— **参考图在设计稿里是必填**。正典也一致:`AI宠物_iOS_UI设计稿.html:329`「上传一张照片,AI 帮你生成专属宠物写真」、`:330` CTA「+ 上传宠物照片」。即产品定位是**图生图(宠物写真)而非通用文生图**。这决定 A1 hero 的 CTA 语义、A2 的表单 gating、以及"能不能纯靠提示词生成"。**必须拍板(D2)**,本规范默认按「必填」写,并在 §2.2 给出「若改为可选」的降级形态。
**(b)M3.5 的「中文本地化」不是 i18n 框架。** 核实:`l10n.yaml` 不存在、`lib/l10n/` 不存在、`AppLocalizations` 全 lib 零引用、`pubspec.yaml``generate: true`。实际接入仅是 `lib/app/app_localization.dart` 挂了 Material/Cupertino/Widgets 三件套 delegate:20-25+ 锁 `Locale('zh','CN')` 单语言(:29、:38),消费点 `app.dart:314-315`。**业务文案全是源码内硬编码中文字面量。** → §5 文案表**不给 i18n key**,直接给中文原文 + 落点,与现仓一致。唯一的既有纪律是 `app_date_picker.dart:75-76`:Material 组件自带文案交给 delegate,不在业务代码重复硬编码。
### 0.7 证据强度声明(按协作方要求降级)
- Flutter 测试基线以协作方实测为准:**597 通过 + 2 skipped**。本规范不引用测试数作为设计结论的依据。
- `integration_test/` 下的真机/桌面实测脚本**完全在 `flutter test` 之外、无任何门禁会跑**。因此 `iteration-3.5/05` 报告 §4 的「桌面逐步实测截图」在本规范中**仅作为设计意图的参考,不作为「该 UI 链路已验证」的证据**。凡本规范引用既有组件行为,一律以源码行号为准,不以实测报告为准。
- 未取证项:AI 提供方的真实生成耗时量级、是否上报进度、是否支持取消 —— 三者均取决于尚未产生的选型 ADR。本规范因此把「进度是否可用」做成可降级设计(§2.3),不押注任何一种。
---
## 1. 页面族总览与通用排版 token
```text
创作 Tab(底栏 index 1,✨ auto_awesomeAppBar 标题「创作中心」)
└─ A1 创作首页(hero + 配额行 + 风格目录 3 列网格 + 右上历史入口)
├─ A2 生成参数页(参考图 + 提示词 + 两级渐进披露)
│ └─ A3 任务等待页(提交后自动进;可离开)
│ ├─ A4 结果预览页(单图原比例 + 重新生成 / 暂不发布 / 发布到社区)
│ │ ├─ A6 全屏结果查看(复用提取)
│ │ └─ → PostComposePage(预填草稿,§2.6
│ └─ S1 配额说明 sheet429 出口)
└─ A5 我的 AI 作品与任务列表(网格 + 状态角标;第二入口在「我的」页菜单)
G1 全局「进行中」指示位(跨 Tab;首页与创作 Tab 顶部条 + 底栏创作 Tab 角标)
```
**入口三处**(均已有正典依据,非新增提案):底栏创作 Tab(`main_shell_page.dart:277-281`)→ A1A1 右上 `history` 钮(正典 `🕘`,:325)→ A5;「我的」页菜单「我的 AI 作品」(正典 :497)→ A5。
**通用排版 token**(延续前三份规范,不新造):
- 页面内边距 `EdgeInsets.fromLTRB(16, 12, 16, 28)`;间距刻度 4 / 8 / 12 / 16 / 24 / 32;卡间距 16,分区间距 24
- 圆角:卡片 `xl` 24Card 主题默认)、输入框 `lg` 18、网格单格与内嵌块 `sm` 12、chip/胶囊 `pill`
- 字级:分区标题 `titleLarge` 18/w800;卡内标题 `titleMedium` 15/w700;正文 `bodyMedium` 14/h1.5;次级 12 **一律显式 `inkSoft`**(不用 `muted`DEBT-2
- Bottom sheet 沿用既有骨架(`showDragHandle` + `useSafeArea` + `isScrollControlled``avatar_upload_sheet.dart:45-48`
- 错误三层模型沿用:字段 `errorText` / 区块 `InlineErrorBanner` + 重试 / 瞬态 SnackBar
- 触控目标 ≥44×44;不足时须在源码显式记录妥协(既有惯例 `comment_tile.dart:114-115``post_media_grid.dart:226`
- `muted` 仅限:输入占位符、禁用态、纯装饰图标
## 2. 页面规范
### 2.1 A1 创作首页(模型 / 风格目录)
替换 `create_page.dart` 的上半部。`_ComposeEntryCard`:394-419T3-17 落地的**真实**发布入口)**保留不动**——它是本页唯一已经真实的东西。
```text
AppBar(主壳既有:leading 头像+Patbond / 标题「创作中心」/ 通知钮)
↑ 主壳 AppBar 不动;A1 自身的「历史」钮放在页面内容首行右侧
(不塞进主壳 actions —— 那是全 5 个 Tab 共用的,塞进去会在其他 Tab 露出)
┌ G1 进行中条(仅有在途任务时出现,§2.7)────────┐ ← 页面第一位
└──────────────────────────────────────────────┘
↓ 12
┌ 发布动态入口(既有 _ComposeEntryCard,原样保留)┐
└──────────────────────────────────────────────┘
↓ 16
┌ AiHeroCard(正典 .ai-hero)────────────────────┐
│ 变成毛孩子的写真 ink 18/w800│ ← 承字改 ink,非白字(§7.2)
│ 上传一张照片,AI 帮你生成专属宠物写真 ink 12/w500│
│ ┌────────────────────────────────┐ │
│ │ + 上传宠物照片 │ 白92%底 │ 正典 .upload-btn
│ └────────────────────────────────┘ primaryDark│
│ ─────────────────────────────────────────────│ 白 40% 分隔线
│ ✨ 今日还可生成 3 次 QuotaMeter │
└──────────────────────────────────────────────┘
↓ 24
热门风格 titleLarge 18/w800
↓ 12
┌──────┐┌──────┐┌──────┐ StyleCard 3 列网格
│ ││ ││ │ crossAxisSpacing 10 / mainAxisSpacing 10
│ 吉卜力││ 迪士尼││ 漫画风│ childAspectRatio 1/1.1(正典 :166
└──────┘└──────┘└──────┘ 格内圆角 sm12
┌──────┐┌──────┐┌──────┐
│ 水彩 ││电影海报││拟人化 │
└──────┘└──────┘└──────┘
↓ 24
(若目录含 video 类)AI 视频 同款 3 列网格,M4 无 video 则整段不渲染
```
**「历史」钮落位**(正典 `🕘`:325):内容首行右对齐一枚 `IconButton(Icons.history, tooltip: '我的 AI 作品')`44×44`inkSoft` 图标。**不放进 `MainShellPage` 的 AppBar actions**`main_shell_page.dart:251-262` 是 5 个 Tab 共用的通知钮位,塞进去会在首页/档案/服务/我的四个 Tab 上错误露出)。
**`AiHeroCard` 规格**(新建):`brandGradient``app_theme.dart:62-66`+ 圆角 `xl` 24(正典 20 收敛到 token+ padding 18。承字 `ink`:标题 18/w800(最不利渐变端 4.90:1)、副标 12/w500(**不用透明度降权,靠字号字重区分**——白 92% 在此渐变上仅 2.0x:1)。CTA 白 92% 底 + `primaryDark` 12/w700 + 圆角 `sm` 12 + 高 44(正典 padding 9 撑不到 44)。分隔线白 40% 1px。
**`StyleCard` 规格**(新建,正典 `.style-card`):
| 态 | 视觉 |
| --- | --- |
| 默认 | `surfaceTint` 底(预览图未到手时的占位色,正典 `peach`+ `border` 1px + 圆角 `sm` 12 + `RemoteImage(fit: cover)` 预览图;底部 `transparent → ink 80%` 竖向渐变 + 白字标题 12/w700 居中 + 副标 10(有则渲染),底 padding 10/6 |
| 选中 | 描边转 `primaryStrong` **2px****不用 `primary`**`primary/canvas` 仅 2.59:1 不达非文字 3:1`primaryStrong/canvas` 4.23:1;现 `create_page.dart:237` 正是 `primary`,§8.5 修订)**+ 右上角 20 圆 `primaryStrong` 实底 + 白 `check` 图标 13**(双通道,不靠颜色单通道传达选中) |
| 按下 | `InkWell` ripple 圆角随格 |
| 不可用 | **M4 不出现**(目录端点只返回 `enabled=true` 的行——服务端已有 partial index `WHERE enabled``patbond_postgresql.sql:601-603`)。若拍板决定返回全量(D7),灰态形态为:整格 `Opacity 0.5` + 移除 `onTap` + 底部字条改「暂不可用」+ 不渲染选中角标 |
| 预览图缺失 | `preview_asset_id` 是 nullable`patbond_postgresql.sql:586`)→ 无预览图时**不留空白格**`surfaceTint` 底 + 居中 `auto_awesome` 28 `primary`(纯装饰,与 `EmptyState``muted` 图标同类,不受对比度约束)+ 底部字条照常 |
**风格预览图从哪来**`creation.generation_styles.preview_asset_id → media.assets``patbond_postgresql.sql:586`),即**走既有两步上传 + 预签名 GET 的同一条路**,客户端拿到的是时效性签名 URL,`RemoteImage` 直接消费(缓存 key 已剥签名参数,`common.dart:25-28`)。**不得持久化 URL**(纪律 R2)。运营侧如何把图灌进去(后台?种子数据?)**未取证** —— 缺一个「风格目录内容管理」的答案,属后端/运营范畴,记入 §10 R7。
**模型(model)怎么呈现 —— 与风格分开**`generation_models``generation_styles` 是两张表(`:556` / `:580`)。设计判断:**A1 只呈现风格,不呈现模型**。模型是实现细节(`provider_model_name`),普通用户不需要在首屏做这个选择;默认取目录 `sort_order` 首个。多模型的选择下沉到 A2 的二级披露(§2.2)。现 `create_page.dart:176-188` 把「创作模型」做成首屏 `DropdownButtonFormField`(列出 `Patbond-V1 / Pet-Art Pro / Cute Motion` 三个假名),**这个层级是错的**,M4 修正。
**`media_kind` 切换器**:现 `create_page.dart:138-157``SegmentedButton<CreationMode>`(AI 图片 / AI 视频)。M4 处置 = **数据驱动**:按目录返回的 `media_kind` 去重后决定;只有一种时**整个控件不渲染**(不给灰态占位——ADR-018 视频后置,契约 `kind` enum 只有 `image`,渲染一个永远点不动的「AI 视频」段是在承诺不存在的能力)。契约扩到 video 时 UI 自动长出第二段。**副作用:M4 因此绕开了这枚粉底控件的这一个使用点,但另 5 处仍在,主题债照旧要偿(§8.1)。**
**状态矩阵**
| 态 | 呈现 |
| --- | --- |
| 首载 | hero 骨架(`surfaceTint` 圆角 `xl` 块)+ 6 格 `surfaceTint` 方块;呼吸动效沿 `feed_skeleton.dart:26-46` 同款(1200ms、`disableAnimations` 时静止在 1.0 |
| 空(目录 0 条) | `EmptyStateIllustration(icon: auto_awesome, title: '暂时没有可用的风格', description: '我们正在准备新的风格,稍后再来看看')`,**不给 CTA**(无处可去);hero 的上传 CTA 同时禁用(选不了风格就发不了任务) |
| 加载失败 | `InlineErrorBanner` + 其下「重试」`TextButton`;hero 保留可见但 CTA 禁用 |
| 进行中 | G1 条在页首(§2.7);hero 与目录**照常可用**(允许排多个任务,上限由配额约束) |
| 成功(就绪) | 如上线框 |
| 无权限 | **不作为页面态存在**。全部端点 401,而 App 登录后才进主壳(`splash_page.dart` → login),未登录到不了 A1;在途 401 由既有 `TokenRefresher` 兜底 |
| 配额耗尽 | 不打断浏览:`QuotaMeter` 转耗尽态(§2.7),hero CTA **仍可点**,到 A2 提交时才拦(**不在 A1 就禁用**——用户可能只是想逛风格,提前禁用会让人以为功能坏了) |
### 2.2 A2 生成参数页
push 全屏页(不用 sheet:有图片选择 + 长文本输入 + 两级披露,sheet 高度不够且键盘弹起时披露区会被压没)。
```text
AppBar:← 返回 / 「生成设置」/ (无右侧 action)
┌ 参考图区 ─────────────────────────────────┐
│ ┌──────┐┌ + ┐ │ PostMediaEditGrid(复用)
│ │ 缩略图││虚线│ ← 上限 1 张,已选后+格隐藏 │ maxImages: 1
│ └──────┘└────┘ │ 每格叠 UploadProgressOverlay
└──────────────────────────────────────────┘
已选风格:#吉卜力 ← TagPill(primary),右侧「更换」TextButton → 返回 A1
↓ 16
┌ 提示词 ───────────────────────────────────┐
│ 想让它变成什么样?(选填) │ 裸 TextField
│ minLines 3 / maxLines null │ hint「例如:坐在樱花树下,暖阳,胶片质感」
│ 0/500 12 inkSoft│ 软上限 500(硬上限契约 10000,D8
└──────────────────────────────────────────┘
↓ 12
┌ 这是哪只毛孩子?(选填)───────────────────┐
│ ( 豆豆 ) ( 花卷 ) ( 不指定 ) │ PetAvatar md44 横排 + 名字 11
└──────────────────────────────────────────┘ (沿 iteration-2/05 §141 切换器先例)
↓ 16
▸ 更多设置 ← 一级披露(默认收起)
画面比例 ( 1:1 )( 3:4 )( 4:3 )( 9:16 )( 16:9 ) ChoiceChip 组
高清增强 [ ●——] 提升毛发与眼睛细节 SwitchListTile.adaptive
↓ 12
▸ 高级 ← 二级披露(默认收起)
不想出现的元素(选填) minLines 2 / 0/200
创作模型 ( Patbond-V1 )( … ) 仅目录 >1 个模型时渲染
↓ 24
┌ 底部固定条(surface 底 + 顶部 border 1px)──┐
│ 今日还可生成 3 次 QuotaMeter 12 inkSoft│
│ ┌──────────────────────────────────────┐ │
│ │ ✨ 开始生成(消耗 1 次) │ │ FilledButton 全宽 52
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘
```
**渐进披露的分层依据**(三层,不是随意分的):
| 层 | 项 | 为什么在这层 |
| --- | --- | --- |
| 常显 | 参考图、已选风格、提示词、宠物 | 参考图是必填(`input_asset_id NOT NULL`);风格是用户从 A1 带过来的,必须可见可改;提示词是本页的主要创作动作 |
| 一级「更多设置」 | 画面比例、高清增强 | 会改变**结果的样子**,用户可能想调,但有合理默认(1:1 + upscale 关) |
| 二级「高级」 | 负面提示词、模型 | 「负面提示词」是专业概念(普通用户不知道要填什么);模型是实现细节。放二级 = 承认它们存在但不打扰 90% 的人 |
**披露控件不用 `ExpansionTile`。** 理由:`ExpansionTile` 的展开图标色、文字色、`collapsedBackgroundColor` 全走 `ColorScheme` 派生值,而 `app_theme.dart` **没有 `expansionTileTheme`**(已核实:`buildAppTheme()` 只定制了 8 个子主题,:105/124/133/146/173/179/180)。直接用它会**复制 `SegmentedButton` 粉底那笔债的成因**(§8.1)。改为自绘披露行 `GenerationParamSection`(新建):`Row``titleMedium` 标题 + `Spacer` + `expand_more`/`expand_less` 20 `inkSoft`)整行高 48 可点,展开区 `AnimatedSize` 200ms `Curves.easeOut`
**画面比例给枚举,不给像素输入。** `width_px`/`height_px` 在设计稿是 64..8192 的自由整数(`patbond_postgresql.sql:616-617``:649-653`),但让用户填像素是灾难。UI 给 5 档比例,客户端按「模型基准边长 × 比例」换算成像素提交。**这需要目录端点给出每个模型支持的比例白名单与基准边长**,否则客户端会算出提供方不支持的尺寸 → §10 诉求 R2、拍板 D5。
**表单 gating(可提交条件)**
- 按 D2 默认(参考图必填):`参考图 ready``MediaUploader.allReady``readyCount == 1`**且** `已选风格非空` **且** 无在途提交。提示词**不**参与 gating(选填)。
- **若 D2 改为「参考图可选」**:gating 降级为 `已选风格非空 && (参考图为空 || 参考图 ready) && (提示词非空 || 参考图非空)` —— 即至少要有「一张图」或「一句话」,两者全空不能提交。同时 A1 hero 的 CTA 文案须从「+ 上传宠物照片」改为「开始创作」(正典文案绑定了「上传照片」这个前提,`AI宠物_iOS_UI设计稿.html:330`)。
- 不满足时按钮走主题禁用态(`ink` 12% 底 + 38% 字,`app_theme.dart:137-138`),**并在按钮上方给一行 12 `inkSoft` 的原因**(「先选一张宠物照片」/「先选一个风格」)—— 只置灰不说原因是既有页面的通病,M4 不复制。
**参考图上传:复用而非重写。** 走既有 `MediaUploader``media_uploader.dart:127-143`+ `PostMediaEditGrid``post_media_grid.dart:128`+ `UploadProgressOverlay``upload_progress_overlay.dart:17`),参数 `maxImages: 1, maxConcurrentUploads: 1, purpose: MediaPurpose.aiInput`。六态(`queued/compressing/uploading/confirming/ready/failed``media_uploader.dart:17-24`)的视觉已在 `UploadProgressOverlay` 里定型,**一格都不用重画**。
**新增 `MediaPurpose` 枚举值**:现有三值 `postImage/userAvatar/petAvatar``community_models.dart:67-75`)→ 加 `aiInput('ai_input')``aiOutput('ai_output')`。**零 Flyway 迁移**`purpose` 无 DB CHECK,白名单是配置项 `MediaProperties.allowedPurposes`ADR-022 决策 5`decisions.md:192`);但引用侧会校验用途相符,给错是 404/40405(`ErrorCode.java:28`)。
**不用 `AvatarUploadSheet`**`avatar_upload_sheet.dart:38`):它是「弹 sheet → 选 → 确认 → 关闭 → 返回 assetId」的一次性交互,适合头像;A2 的参考图要**与其他表单项同屏共存**(用户会边看图边改提示词),塞进 sheet 就看不见了。改为内嵌 `PostMediaEditGrid`
**`AppTextField` 用不了。** 已核实它**没有 `maxLines`/`minLines`/`maxLength` 参数**`app_text_field.dart:9-24`)。两条路:(a) 给它加这三个参数(一次改好,`post_compose_page.dart:575-585` 的裸 `TextField` 也能顺手收敛);(b) 照发布页写裸 `TextField`。**推荐 (a)**:M4 有两个长文本框(提示词 + 负面提示词),加上发布页正文共三处,散着写三份 decoration 必然漂移。
**提示词计数器软上限 500**:契约硬上限 10000`patbond_postgresql.sql:614`)。给用户看 `0/10000` 会鼓励写小作文,而多数提供方对超长 prompt 的效果反而更差。计数器显示 `N/500`,到 500 时**软拦**(提示「再长也不会更准,试试精简一点」+ 仍允许继续输入到硬上限)——不硬截断,避免用户粘贴长文时内容被吃掉。拍板 D8。
**状态矩阵**
| 态 | 呈现 |
| --- | --- |
| 空(初始) | 参考图区只有「+」虚线格;提示词空 + hint;披露区收起;提交钮禁用 + 原因行 |
| 加载 | **本页无首载请求**(风格/模型从 A1 带入)。宠物列表若未预取则该区显示 3 个 44 圆 `surfaceTint` 占位 |
| 进行中(参考图上传) | 该格叠 `UploadProgressOverlay` 四视觉态;提交钮禁用 + 原因行「照片还在上传」 |
| 成功(就绪) | 提交钮可用,文案带「(消耗 1 次)」 |
| 提交中 | 钮内 18 白转圈 + 文案「正在提交…」,整页输入 `AbsorbPointer`(防重复提交产生两个 job |
| 失败(参考图) | 格内失败态 + 可重试则给「重试」通栏(`upload_progress_overlay.dart:71-104`);压缩后仍超 10 MiB 时 `retryable:false`**只给「重新选择」**(既有纪律,`media_uploader.dart:350-356` |
| 失败(提交) | 顶部 `InlineErrorBanner` + 分码文案(§5);**不清空任何已填内容**;参考图的 assetId 保留(不必重传) |
| 配额耗尽(429) | 提交被拒 → 弹 S1 sheet(§2.7),页面内容全保留;`QuotaMeter` 就地转耗尽态 |
| 无权限 | 同 A1,不作为页面态 |
### 2.3 A3 任务等待页 ★ 本迭代最关键的设计问题
**形态裁决:提交成功后 `pushReplacement` 进 A3(替换掉 A2),A3 明确允许离开。**
比较过的三条路:
| 方案 | 形态 | 判定 |
| --- | --- | --- |
| 甲 阻塞式 | 全屏遮罩不可返回,等到出结果 | **弃用**。分钟级任务把用户锁在一个页面上是不可接受的;且杀进程即丢,用户会以为额度白扣了 |
| 乙 提交即散 | 提交后直接回 A1,只留 G1 条 | **弃用**。点了「开始生成」却立刻回到原页面,用户拿不到「事情真的开始了」的确认,第一反应是再点一次(→ 重复扣额度) |
| 丙 进等待页 + 可离开 | 自动进 A3 看到进度;页面显式说明可以离开;离开后靠 G1 回来 | **采纳**。既给出即刻确认,又不绑住用户 |
`pushReplacement` 而非 `push`:否则用户从 A3 返回会回到 A2(一个已经提交过的表单),再点一次「开始生成」就是第二个 job。替换掉 A2 后,返回直接回 A1。
```text
AppBar:← 返回 / 「正在生成」/ 右侧「取消」TextButtoninkSoft
┌──────────────────────────────────────────┐
│ │
│ ◜◝ │ GenerationProgressPanel
│ ◟ ◞ ← 不确定进度环 56 │ primaryStrong 值条
│ surfaceTint 轨道 │ 轨道 3.80:1(非文字达标)
│ │
│ 正在生成… titleLarge│ ← 阶段文字为主
│ 已经等了 42 秒 12 inkSoft │ ← 已用时,不是预计剩余
│ │
│ ● 已排队 ✓ │ 阶段步进(三段)
│ ● 生成中 ← 当前,primaryStrong 加粗 │
│ ○ 收尾 muted │
│ │
│ ───────────────────────────────────── │
│ 可以先去做别的,回到 App 就能看到结果 │ 12 inkSoft,居中
│ (不承诺推送,见下) │
└──────────────────────────────────────────┘
↓ 16
参考图缩略(96 圆角 sm12+ #吉卜力 TagPill + 提示词首行截断
← 让用户在等待中确认「我提交的是这个」
```
**进度表达的四条裁决**(依据 §0.3 的 CHECK 铁律):
1. **主形态是「阶段文字 + 不确定进度环」,不是百分比。** 因为 `queued``progress` 恒 0(硬约束),`running``progress` **无下限约束**(提供方可能全程不上报)。若把百分比当主形态,最常见的用户观感就是「0% 卡了半天」——这比没有百分比糟得多。
2. **`progress > 0` 时才叠数字**,形态从不确定环切换为确定环 + 环心 `42%` 15/w800 `ink`。切换时不做重置动画(环角度从当前位置续上)。
3. **绝不展示预计剩余时间。** ETA 需要提供方支持 + 队列深度模型,M4 两者都没有(AI 提供方 ADR 尚未产生)。倒计时走完还没完成是最伤信任的反模式。
4. **改展示「已用时」。** 已用时是客观事实、客户端自己就能算(提交时刻起,或服务端 `started_at`),不构成任何承诺。文案「已经等了 42 秒」→ 超 2 分钟改「已经等了 2 分 15 秒」(不显示毫秒/不显示小时)。
**排队位次**:若服务端能给 `queuePosition`(离散事实,可信),`queued` 态文案升级为「前面还有 3 个任务」;给不出就只说「已排队,正在等待空闲的算力」。**不自己编排位**。→ §10 诉求 R3。
**阶段步进只做三段,不做四段。**`create_page.dart:539` 的假阶梯是四段(`分析宠物特征 / 加载风格模型 / 生成画面细节 / 高清增强与合成`)——那是**编造的**内部步骤,服务端的 status 只有 `queued/running/succeeded/...`,映不出四段。M4 三段严格对应:`queued`→「已排队」、`running`→「生成中」、`running && progress>=95`(或 `succeeded` 前的过渡)→「收尾」。**不编造服务端没有的阶段。**
- 「收尾」这一段是否成立取决于 progress 是否可信。若拍板决定「progress 不保证」(D4 推荐),则**只做两段**(已排队 / 生成中),第三段不渲染。宁可少一段真的,不要多一段假的。
**用户能否离开 —— 能,而且这件事必须写在屏幕上。** 底部说明行是本页的必需元素,不是装饰。
**⚠ 文案纪律:不能说「完成后通知你」。** 已核实客户端**没有任何推送能力**(无 WebSocket、无 SSE、无 FCM/APNs 依赖、无相关端点)。「通知你」会让用户锁屏等推送,然后什么也不来。正确文案:**「可以先去做别的,回到 App 就能看到结果」**。这是本节最容易写错、且错了以后用户会明确认为产品骗人的一处。
**离开后如何回来 —— 三条路,按显著性排序**
| # | 路径 | 位置 |
| --- | --- | --- |
| 1 | G1 全局进行中条 | 首页与创作 Tab 的页面第一位(§2.7) |
| 2 | 底栏创作 Tab 图标角标 | `main_shell_page.dart:277-281` 的 icon 外包 Material `Badge`(无数字小圆点) |
| 3 | A5 我的 AI 作品列表 | A1 右上 `history` 钮 / 「我的」页菜单 |
**轮询策略(UI 侧诉求,实现属客户端任务层)**:无推送 → 只能轮询。
- 仅在**前台可见**时轮询;`AppLifecycleState.paused` 停止;回前台立即补一次(不等下一个 tick)
- 退避阶梯:030 s 每 3 s30 s2 min 每 5 s2 min10 min 每 10 s;超 10 min 每 30 s
- 杀进程恢复:**只持久化 jobId 列表**(`SharedPreferences`),重启后拉一次状态。**不持久化任何 URL**(纪律 R2:预签名 URL TTL 1 h,持久化必得 403/404
- 这一层客户端**零先例**(§0.5 第 2 条),是本迭代最大的新建块
**第六个 UI 态:僵死 / 超时。** 服务端只有 5 个 status,但 `running` 可能长时间无进展(提供方挂了、lease 过期重试中)。UI 必须有第六态,且**它不是错误态**:
- 触发:`running` 且已用时 > 10 min(阈值待拍板 D10
- 呈现:进度环转 `accent` 色系 + 文案「还在生成,比平时久一些」+ 副行「你可以继续等,或者取消这次生成」+ 「取消」钮从 AppBar 提升为页内 `OutlinedButton`(提高可见性)
- **不报错、不自动取消**(自动取消会丢掉一个可能马上就成功的任务)
**取消**`development-plan.md:249` 明确 M4 要做「查询和取消」。
- 入口:AppBar 右侧「取消」TextButton(僵死态时提升为页内钮)
- 二次确认 `AlertDialog`:「取消这次生成?」+ 内容按 D11 拍板结果二选一 ——「已消耗的额度不会退还」或「额度会退还」。**这句话必须说准**,说错方向用户会觉得被坑
- `cancelled` 后 A3 转终态:`EmptyStateIllustration(icon: cancel_outlined, title:'已取消这次生成', ctaLabel:'再试一次')` → 回 A1
- **`queued``running` 都允许取消**(设计稿的 `cancelled` 态对 output/error 无约束,两处都能进)
**状态矩阵**
| 态 | 服务端 status | 呈现 |
| --- | --- | --- |
| 加载(首次查询前) | — | 进度环不确定 + 「正在提交…」;**不显示已用时**(还没有起点) |
| 排队中 | `queued` | 不确定环 + 「已排队」+ 已用时 +(有 queuePosition 则)「前面还有 N 个」 |
| 生成中(无进度) | `running``progress==0` | 不确定环 + 「正在生成…」+ 已用时 |
| 生成中(有进度) | `running``progress>0` | 确定环 + 环心百分比 + 「正在生成…」+ 已用时 |
| 僵死 / 超时 | `running` 且已用时 > 阈值 | 见上第六态(`accent` 环 + 提升的取消钮)。**非错误态** |
| 成功 | `succeeded` | 环补满 100% → 200 ms 停顿 → **自动 `pushReplacement` 进 A4**(不让用户再点一次「查看结果」;结果就是他等的东西) |
| 失败 | `failed` | 见下失败态设计 |
| 已取消 | `cancelled` | `EmptyStateIllustration` 终态 + 「再试一次」 |
| 轮询请求本身失败 | — | **不打断等待**:静默重试下一个 tick;连续 3 次失败才在页面底部加一行 12 `inkSoft`「网络不太稳,正在重连…」。任务在服务端照常跑,把网络抖动升级成错误页是过度反应 |
| 空 / 无权限 | — | 不存在(本页必然有一个 jobId 才能到达;jobId 查不到 → 404 走失败态) |
**失败态设计**`failed``error_code` 非空是硬约束):
```text
┌──────────────────────────────────────────┐
│ ⊘ 56 errorDark │ 非 EmptyStateIllustration
│ 这次没有生成成功 titleLarge │ (那个是"空"语义,不是"失败"
│ 参考图里没有识别到宠物,换一张试试 bodyMedium inkSoft
│ │
│ ┌───────────────┐ ┌─────────────────┐ │
│ │ 换个参考图 │ │ 用同样设置重试 │ │
│ └───────────────┘ └─────────────────┘ │
│ OutlinedButton FilledButton │
│ 这次失败不消耗额度 ← 若 D11 定为不扣 │
└──────────────────────────────────────────┘
```
- 文案按 `error_code` 分层(§5 文案表),**兜底文案不能是「未知错误」**:给「这次生成没成功,换个参考图或稍后再试」+ 折叠区可展开看 `error_message`(给愿意看的人,`varchar(1000)`
- `attempt_count` / `max_attempts``patbond_postgresql.sql:628-629`):服务端已自动重试过 N 次。UI **不暴露内部重试次数**(用户不关心 worker 重试了 3 遍),但若 `attempt_count == max_attempts` 则「用同样设置重试」按钮改为次要态并在下方加一行「同样的设置已经试过几次了,建议换张照片」
- 失败**是否退额度**是 D11 的一部分,必须与文案一致
### 2.4 A4 结果预览页
由 A3 成功后 `pushReplacement` 进入(返回键回 A1,不回 A3——那是个已终结的等待页)。
```text
AppBar:← 返回 / 「创作完成」/ 右侧 share_outlined(系统分享,可选,D14
┌──────────────────────────────────────────┐
│ │
│ 结果图 —— 按真实比例渲染 │ ★ 不是 4:3
│ 通栏出血,圆角 0(贴边) │ 点击 → A6 全屏
│ 比例来自 job 的 width/height │
│ │
└──────────────────────────────────────────┘
↓ 16(页面水平 padding 16 从此处起)
#吉卜力 · 1:1 · 高清增强 TagPill + 12 inkSoft
提示词:坐在樱花树下,暖阳,胶片质感 12 inkSoft,≤2 行截断 +「全文」
↓ 24
┌──────────────────────────────────────┐
│ 发布到社区 │ FilledButton 全宽 52
└──────────────────────────────────────┘ primaryStrong 底白字 4.49:1
↓ 12
┌──────────────┐ ┌──────────────────┐
│ 重新生成 │ │ 暂不发布 │ OutlinedButton × 2 等分,高 48
└──────────────┘ └──────────────────┘
「重新生成」下方:将消耗 1 次额度 11 inkSoft
↓ 16
作品已存在「我的 AI 作品」里,之后也能发布 12 inkSoft 居中
```
**单图 / 多图:M4 恒单图。** 依据 `patbond_postgresql.sql:613` `output_asset_id uuid` 是**单列**,一个 job 对应一个输出资产;多图需要 job→N assets 的关系表,设计稿没有。因此:
- A4 按单图设计。**不用 `PostMediaGrid`** —— 它单图走 `_CollapsedCover` 且硬编码 `AspectRatio(4/3)``post_media_grid.dart:326-327`),正是本页要避免的东西。
- 多图预留(不实现):若将来一个 job 出 N 张,形态为 `PageView` 横滑 + 底部页码指示 + 右上「2/4」`ink` 胶囊白字(13.50:1),沿 `iteration-3/05 §2.2` 详情页多图的既定语言。
**结果图的比例从哪来 —— 这里不受 widthPx 债影响。** A4 的比例取自**本次 job 的 `width_px`/`height_px`**`patbond_postgresql.sql:616-617`),那是**客户端自己刚刚提交的参数**,一定知道。所以 A4 能画出完整正确的构图。**债在发布之后才显现**(Feed 读的是 `media.assets` 的尺寸,恒 null)—— 这个前后对照正是 §8.3 判定「M4 由隐性升为显性」的核心论据。
- 保护性钳制:即使比例已知,也钳到 `[9/16, 16/9]`0.5625–1.778)防异常值撑爆页面;超出时 `BoxFit.contain` + `canvas` 底衬(**不裁切**——用户刚花额度生成的图,宁可留白不可裁)。
**三个动作的语义与文案,逐个都有讲究**
| 动作 | 形态 | 设计判断 |
| --- | --- | --- |
| 发布到社区 | 主 CTA`FilledButton` 全宽 | 这是产品希望发生的事,给最高视觉权重 |
| 重新生成 | 次要,`OutlinedButton` | **必须在按钮上写明「将消耗 1 次额度」**。这是会花钱的操作,把代价藏在点击之后是暗黑模式。同参数直接重提(不回 A2),但**换新的 `idempotency_key`**(否则命中 `UNIQUE(user_id, idempotency_key)` 会返回同一个旧 job,用户看到「什么都没发生」) |
| 暂不发布 | 次要,`OutlinedButton` | **不叫「丢弃」/「删除」。** job 已在服务端存在、图也在 `media.assets` 里,点这个只是「现在不发帖」。叫「丢弃」会让用户以为作品被销毁,从而不敢点。配一行说明「作品已存在「我的 AI 作品」里,之后也能发布」。真要删是 A5 的删除动作 |
**放大查看 → A6。** 复用既有 `_MediaGalleryPage``post_detail_page.dart:880-985`):黑底 + `PageView` + `InteractiveViewer(maxScale:4)` + 双击 2.5× 缩放(:904-914+ 右上「n/N」`ink` 胶囊 + 关闭钮 `tooltip:'关闭'`:953-957)。它现在是 **`post_detail_page.dart` 的私有类**,需**提为共享 `MediaGalleryPage`**`lib/core/widgets/media_gallery_page.dart`)。单图时不渲染页码胶囊(既有逻辑 `:959` 已判 `urls.length > 1`),零改造。
- 已知遗留(不在 M4 修):下滑关闭手势与 `InteractiveViewer` 平移冲突,`post_detail_page.dart:878-879` 已登记「留待手势方案升级(photo_view 复评条件:体验不达标)」。M4 沿用现状,只提取不改行为。
**状态矩阵**
| 态 | 呈现 |
| --- | --- |
| 加载(图片下载中) | `RemoteImage` 自带 loading`surfaceTint` 底 + 18 转圈(`common.dart:32-44`)。**外层不再叠一层骨架**(会双重转圈) |
| 图片下载失败 | `RemoteImage` 自带兜底:`surfaceTint` + `Icons.pets 34 muted``common.dart:45-56`)+ 其下一行「图片加载失败,下拉重试」+ 三个动作钮**照常可用**(图没显示不等于作品不存在) |
| 成功 | 如上线框 |
| 进行中 | 点「发布到社区」后:钮内转圈 + 另两钮禁用(防并发建草稿);点「重新生成」后:`pushReplacement` 回 A3 |
| 失败(建草稿失败) | `InlineErrorBanner` 在图下方 + 重试;**停在本页不跳转**(跳到发布页会得到一个没有图的空草稿) |
| 配额耗尽 | 只影响「重新生成」:该钮转禁用 + 下方文案改「今日额度已用完,明天再来」;「发布到社区」**不受影响**(发帖不消耗生成额度) |
| 空 / 无权限 | 不存在(有 `succeeded` job 才能到达本页) |
### 2.5 A5 我的 AI 作品与任务列表
一个列表承载**全部五种 status**(不做「作品」与「任务」两个分开的列表——用户心智里它们是同一串东西的不同阶段)。
```text
AppBar:← 返回 / 「我的 AI 作品」
┌──────┐┌──────┐┌──────┐ 3 列网格(与 A1 风格网格同栅格)
│ 图 ││ ◜◝ ││ ⊘ │ childAspectRatio 1:1
│ ││ 生成中 ││ 失败 │ 格间距 4(沿 PostMediaGrid 的 _spacing
└──────┘└──────┘└──────┘ 圆角 sm12
┌──────┐┌──────┐
│ 图 📣 ││ 图 │ ← 📣 = 已发布到社区的角标
└──────┘└──────┘
触底加载更多 / 「没有更多了」12 inkSoft 居中
```
**`GenerationJobCard`(网格单格)五态**(新建):
| status | 格内视觉 | 点击去向 |
| --- | --- | --- |
| `succeeded` | 结果图 `BoxFit.cover`;已发帖的加右上 `campaign` 14 白图标衬 `ink` 80% 圆(7.10:1) | A4(结果页,动作行按已发布状态调整) |
| `queued` | `surfaceTint` 底 + 居中 `schedule` 24 `inkSoft` + 底部字条「排队中」11 白字衬 `ink` 80% | A3 |
| `running` | `surfaceTint` 底 + 居中 20 不确定转圈 `primaryStrong` + 底部字条「生成中」 | A3 |
| `failed` | `error` 12% 底 + 居中 `error_outline` 24 `errorDark`6.50:1+ 底部 `errorDark` 实底白字「失败」 | A3 的失败态 |
| `cancelled` | `canvas` 底 + 居中 `cancel_outlined` 24 `muted`(禁用语义,`muted` 合法用途)+ 底部字条「已取消」 | A3 的取消终态 |
- 长按 → 操作 sheet:「保存到相册 / 删除」(删除需二次确认;**这里才是真的删**,与 A4 的「暂不发布」区分)
- 「保存到相册」需新依赖(`gal` / `image_gallery_saver` 之类)—— **未取证**该依赖是否可接受,记入 D13
**这里需要月份筛选吗 —— 不需要。** 若产品要「按月看作品」,才会撞上 §8.2 的月份网格缺失。本规范**建议 M4 不做时间筛选**(作品量级在 M4 不足以需要筛选,游标分页够用),从而**不触发那笔债**。
**状态矩阵**
| 态 | 呈现 |
| --- | --- |
| 首载 | `AiWorkGridSkeleton`9 格 `surfaceTint` 方块 + 呼吸动效(沿 `feed_skeleton.dart` 同参数)。**不能直接用 `FeedSkeleton`** —— 它是单图卡形态(头像行 + 4:3 块 + 两条文本条,`feed_skeleton.dart:60-95`),与网格完全不同 |
| 空 | `EmptyStateIllustration(icon: auto_awesome, title:'还没有 AI 作品', description:'上传一张宠物照片,试试把它变成写真', ctaLabel:'开始创作')` → A1 |
| 加载失败 | `InlineErrorBanner` + 重试 |
| 翻页失败 | 列表尾部一行「加载失败,点击重试」12 `primaryStrong`(白底 4.49:1);**已加载的格子保留** |
| 进行中 | 混在列表里(`queued`/`running` 格),同时 G1 条在页首 |
| 成功 | 如上线框 |
| 无权限 | 不存在(`/me/...` 语义,只看自己的) |
### 2.6 A4 →「一键建社区草稿」的过渡
**推荐路线:服务端建草稿,客户端只带 draftPostId 进发布页。**
```text
A4 点「发布到社区」
↓ POST /posts { status:'draft', category:'ai_creation',
│ generationJobId: <jobId>,
│ media:[{ assetId: <outputAssetId>, position:0, isCover:true }],
│ content:'' } ← 正文空,见下
↓ 拿到 { id: draftPostId, version }
↓ push PostComposePage(initialDraftPostId: draftPostId)
发布页:拉这一条草稿 → 渲染图 + 锁定分类 + 正文空待填
```
比较过的两条路:
| 路线 | 做法 | 判定 |
| --- | --- | --- |
| 甲 服务端建草稿 | 如上;发布页从「拉最新草稿」改为「拉指定草稿」 | **采纳**`ai_creation` 分类与 `generation_job_id` 的挂接在服务端一次完成;且**复用了发布页已有的草稿恢复通路**(`post_compose_page.dart:194-220` 已经会恢复 `draft.media`),改动面最小 |
| 乙 客户端预填 | 发布页加 `initialContent`/`initialCategory`/`initialAssetIds`,首次存草稿时才带 jobId | 弃用。要新增 3 个参数 + 发布页要能提交 `generationJobId`(写侧 DTO 也得改),改动面更大,且「AI 帖的元数据挂接」这件事被摊到客户端 |
**预填了什么 —— 逐项**
| 项 | 预填 | 理由 |
| --- | --- | --- |
| 媒体 | ✅ AI 输出图 1 张(服务端已挂在草稿上) | 这是发帖的全部内容 |
| 分类 | ✅ `ai_creation`,且**锁定不可改** | 改成 general 会让 `generation_job_id` 挂在一个非 AI 帖上,语义漂移。呈现为**静态 `TagPill`** 而不是可选 `ChoiceChip` |
| 正文 | ❌ **不预填,只给 hint** | 见下,这是本节最关键的判断 |
| 话题 | ❌ | 话题在客户端**完全不存在**(`post_compose_page.dart:288-289` `topicCount: 0` 硬编码,注释「话题无契约端点,M3 恒 0」;ADR-018 已把话题剪出)。M4 不引入 |
| 位置 | ❌ | 现为假入口(`post_compose_page.dart:607-613`),M5 服务域 |
| 提示词 | ❌ 默认不公开,见 D9 | prompt 可能含隐私(他人姓名、地点)。给一个「附上创作提示词」开关,**默认关** |
**为什么正文不预填 —— 与 M3.5 的既有纪律同源。** 现 demo 会预填「刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨」(`create_page.dart:90`)。这与 M3.5 定下的「编辑页不预填 username」是**同一类错误**`iteration-3.5/05 §2.1`):**一个纯展示用的机器文案一旦预填进输入框,用户按一下发布就把它固化成真实数据**。后果具体且可预期 —— Feed 会被同一句话灌满,AI 帖立刻变成可识别的垃圾内容。
替代方案(既不留空白页,也不代替用户说话):
- 正文 `hint`:「说说这张图的灵感…」(hint 用 `muted` 合法)
- 正文框下一行**可点的建议标签**`( #吉卜力风 )( 提示词 )( 我家豆豆 )` —— 用户**主动点**才把对应内容插进正文。把「省事」和「代写」区分开
- 发布 gating 沿用既有 `_canPublish``post_compose_page.dart:159-163`):`content` 非空是后端必填,所以**用户必须自己写一句话才能发**。这不是障碍,是防灌水的最后一道闸
**发布页必须改的两处**(否则这条链路是坏的):
1. **`initialDraftPostId` 参数 + `_restoreLatestDraft` 改造。** 现在它无条件拉「最新一条 draft」(`post_compose_page.dart:196-199`)并**无条件覆盖正文**(:203)。若用户本来就有一条旧草稿,从 A4 进来会恢复错的那条。需改为:给了 `initialDraftPostId` 就按 id 拉,否则保持现行为。
2. **⚠ 服务端草稿的图现在只显示计数文字,不渲染缩略图。** 已核实 `post_compose_page.dart:620-638``_uploader.isEmpty && _draftMedia.isNotEmpty` 时只输出一行文字「草稿已含 N 张图片(发布时保留;重新选图将整组替换)」。对 M3 的旧草稿场景勉强够用;**对 M4 是明确缺陷** —— 用户刚在 A4 看过完整的图,进发布页却只看到一行字,会以为图丢了。修法:把 `_draftMedia``PostMediaGrid`(展示态,`urls``_draftMedia[i].url`)渲染出来。**零新建组件。**
- 注意:`PostMediaGrid` 单图会回落 4:3`post_media_grid.dart:326-327`),此处**可接受**(发布页是编辑上下文,不是最终呈现),但若一并按 §8.3 修好 `PostMediaGrid` 读比例,这里也顺带正确。
3. `PostEntryPoint` 枚举加值:现有 `createTab/feed/topicDetail/petDetail``post_analytics.dart:19-28`),加 `aiResult`(入口归因,埋点侧需同步 → 交由埋点规格)。
### 2.7 G1 全局「进行中」指示位 + S1 配额说明
**要不要一个持久的任务入口 —— 要,但要最轻的那种。** 理由:无推送能力(§2.3),用户离开 A3 后**唯一**能知道任务状态的途径就是自己回来看。没有全局提示位,「离开」这件事就等于「失联」。
**G1 由两个部件组成**(都是客户端首例,§0.5):
| 部件 | 形态 | 位置 |
| --- | --- | --- |
| `ActiveJobBar` 进行中条 | 高 52 的 `surfaceTint` 底圆角 `sm` 12 横条:左 20 不确定转圈 `primaryStrong` → 10 → 文字(`primaryDark`7.98:1)→ `Spacer``chevron_right` 20 `primaryDark`。整条可点 → A3 | **页面第一位**(首页 ListView children 首位、创作 Tab 首位) |
| Tab 角标 | Material `Badge`(**内置组件,客户端首次使用**)包住底栏创作 Tab 的 `auto_awesome_outlined` 图标。**小圆点无数字**`Badge``smallSize` 形态),`primaryStrong` 底(白底 4.49:1 达非文字 3:1 | `main_shell_page.dart:277-281` |
**为什么条放在「页面第一位」而不是别处**
- **不能悬浮**`FloatingActionButton`/`Overlay` 全 lib 零先例,凭空引入一层悬浮层要处理键盘、sheet、滚动遮挡,成本远超收益)
- **不能塞进主壳 AppBar**(那是 5 个 Tab 共用的,会在档案/服务/我的页错误露出)
- **首页放在天气条之上不与 ADR-022 冲突**ADR-022 决策 D3.5-1 钉死的是「天气条 / 圈子 / 促销卡三项刻意保留、不得顺手清理」(`decisions.md:188`),且 `home_greeting_test.dart` 有反向用例钉住「保留项仍在」。G1 是**在其上方插入一个状态条**,不移动、不删除、不改动任何 demo 占位 → 那条反向测试不会挂。这是唯一零冲突的插入位。
- 只在**有在途任务时**渲染(`queued`/`running` 存在),无任务时整条不占位(不留空 `SizedBox` 高度)
**三种条文案**(对应三种状态):
| 情形 | 文案 | 视觉差异 |
| --- | --- | --- |
| 1 个在途 | `正在生成你的作品…` | 转圈 + `primaryDark` 字 |
| N 个在途 | `2 个作品正在生成…` | 同上 |
| 刚完成、用户还没看 | `作品生成好了,来看看 →` | 转圈换 `check_circle` 20 `successInk`7.90:1**不用 `success`**,白/淡底上仅 2.292.67:1 不达非文字 3:1,§7.2 D10);底色转 `successSurface`,字转 `successInk`6.79:1 |
- 「已完成」态在**用户点进去看过之后**消失(不是超时消失)。角标同步。这是唯一让用户不会漏掉结果的规则。
- 完成时若 App 在前台且轮询到 `succeeded`:额外弹一条 SnackBar「作品生成好了」+ action「查看」。**不弹对话框**(用户可能正在打字/浏览,抢焦点是敌意行为)。
**S1 配额说明 sheet**429 的出口):
```text
┌ drag handle ─────────────────────────────┐
│ 今日额度已用完 titleLarge │
│ │
│ 每天可以免费生成 5 次,明天 0 点重置 │ bodyMedium inkSoft
│ ▓▓▓▓▓▓▓▓▓▓ 5 / 5 QuotaMeter │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 看看我的作品 │ │ FilledButton → A5
│ └──────────────────────────────────┘ │
│ 去社区逛逛 TextButton → 首页 │
└──────────────────────────────────────────┘
```
**配额耗尽时不给「升级会员」按钮。** 正典有会员卡「AI 会员 Plus / 无限 AI 生图」(`AI宠物_iOS_UI设计稿.html:489-493`),但 M4 **没有任何支付能力**`development-plan.md` 把优惠/订单列在 M5)。给一个点了通向死路的升级按钮,比不给更伤——用户会认为产品在钓鱼。M4 只给两个**真的能去**的出口。拍板 D12。
**两类 429 必须分开**(契约层诉求 R4):
| 类别 | 文案 | 出口 | 为什么不能合并 |
| --- | --- | --- | --- |
| 配额耗尽 | 「今日额度已用完」+ 重置时间 | S1 sheet(看作品 / 逛社区) | 等的是**明天**,用户该走开 |
| 频率过快 | 「操作太频繁,请 12 秒后再试」+ 倒计时 | **SnackBar,不弹 sheet** | 等的是**几秒**,用户该原地待一下。弹 sheet 是过度反应 |
**`QuotaMeter` 规格**(新建,三处共用:A1 hero、A2 底条、S1):横向细条 `LinearProgressIndicator` `minHeight 6` + 圆角 `pill`**值条 `primaryStrong` / 轨道 `surfaceTint`**3.80:1 达非文字 3:1**不能用 `primary` 值条** —— `primary/surfaceTint` 仅 2.33:1+ 右侧 `3/5` 12/w600 `inkSoft`。耗尽态值条转 `errorDark`6.50:1+ 文字转「已用完」`errorDark`。**紧凑形态**(hero 内)只给一行文字不给条。
## 3. 状态矩阵总表
各页详细矩阵见 §2 对应小节,此处为交叉核对表(`—` = 该态在该页不存在,且是**定型而非遗漏**)。
| 界面 | 空 | 加载 | 进行中 | 成功 | 失败 | 无权限 |
| --- | --- | --- | --- | --- | --- | --- |
| A1 创作首页 | `EmptyStateIllustration` 无 CTA + hero CTA 禁用 | hero 块 + 6 格骨架 | G1 条在页首,目录照常可用 | 线框态 | `InlineErrorBanner` + 重试 | — |
| A2 参数页 | 初始态(+格 / 空提示词 / 钮禁用 + 原因行) | 无首载;宠物区 3 圆占位 | 参考图 `UploadProgressOverlay` 四态 | 钮可用带「消耗 1 次」 | 上传格内失败 + 重试 / 提交失败顶部横幅**不清内容** | — |
| A3 等待页 | — | 不确定环 +「正在提交…」无已用时 | **本页主态**(排队 / 生成中 / 僵死三分支) | 环补满 → 自动进 A4 | 独立失败版式(非 EmptyState)+ 两个出口 | — |
| A4 结果页 | — | `RemoteImage` 自带(不叠外层骨架) | 建草稿中:主钮转圈 + 另两钮禁用 | 线框态 | 建草稿失败横幅,**停在本页** | — |
| A5 作品列表 | `EmptyStateIllustration` + CTA「开始创作」 | 9 格 `AiWorkGridSkeleton` | 混在列表里的 queued/running 格 | 网格 | 首载横幅 + 重试 / 翻页失败保留已加载 | — |
| A6 全屏查看 | — | `RemoteImage` 自带 | — | 黑底 + 缩放 | 图失败兜底(`Icons.pets` | — |
| S1 配额 sheet | — | — | — | 线框态 | — | — |
| G1 全局位 | 无在途任务 → **整条不渲染不占位** | — | 三种条文案 | 「生成好了 →」态(点过才消失) | 轮询连挂 3 次 → 条内加「网络不太稳」副行 | — |
**「无权限」整列为 `—` 是定型**:所有 AI 端点都需认证(401),而 App 登录后才进主壳(`splash_page.dart` → login),未登录到不了任何一页;在途 401 由既有 `TokenRefresher` 兜底,不构成页面态。这与 `iteration-3.5/05 §3.2`「资料页没有独立空态是定型而非遗漏」是同类的显式声明。
**A3/A4/A6 的「空」态为 `—` 也是定型**:到达它们的前提就是「有一个 job / 有一个 succeeded job / 有一张图」,空态在逻辑上不可达。jobId 查不到 → 404 → 走失败态,不是空态。
## 4. 组件清单(复用 vs 新建)
**账目:新建 8,复用 16(其中 6 需小改)。**
### 4.1 直接复用,零改动(10)
| # | 组件 | 位置 | M4 用在哪 |
| --- | --- | --- | --- |
| 1 | `RemoteImage` | `lib/widgets/common.dart:5-60` | 风格预览、结果图、A5 网格;三态(loading `surfaceTint`+转圈 / 失败 `Icons.pets` / 成功)全部够用 |
| 2 | `SectionCard` | `common.dart:62-77` | A2 各表单分组 |
| 3 | `TagPill` | `common.dart:86-132` | 「已选风格」标签、发布页锁定的 `ai_creation` 分类标签;用其既有深变体映射 `_defaultInkFor`:103-109 |
| 4 | `EmptyStateIllustration` | `empty_state_illustration.dart:10-78` | A1 空态、A5 空态、A3 取消终态 |
| 5 | `InlineErrorBanner` | `inline_error_banner.dart:8-41` | A1/A2/A4/A5 的区块错误 |
| 6 | `PostMediaGrid` 展示态 | `post_media_grid.dart:21-110` | 发布页渲染草稿图(§2.6 修法);**A4 结果图不用它**(4:3 回落) |
| 7 | `PostMediaEditGrid` | `post_media_grid.dart:128-182` | A2 参考图(`maxImages:1`,「+」虚线格与删除角标全现成) |
| 8 | `UploadProgressOverlay` | `upload_progress_overlay.dart:17-131` | A2 参考图六态 → 四视觉态,一格不用重画 |
| 9 | `MediaUploader` | `media_uploader.dart:127-565` | A2 参考图编排:状态机、并发槽、凭据 30 s 余量换新(:170、:377-397)、压缩阶梯 `[80,60]`:173)、超限 `retryable:false`:350-356)全部沿用 |
| 10 | `PetAvatar` + `PrimaryButton` | `pet_avatar.dart` / `primary_button.dart:8-47` | A2 宠物选择器用 `PetAvatarSize.md` 44(恰为最小触控目标);全宽按钮沿用 |
`EmptyState``common.dart:134-156`:M4 不用 —— 已核实它**无 CTA 能力**,M4 一律用 `EmptyStateIllustration`。列此以说明该判断是核实过的,不是漏看。)
### 4.2 复用但需小改(6
| # | 组件 / 枚举 | 位置 | 要改什么 | 规模 |
| --- | --- | --- | --- | --- |
| 11 | `AppTextField` | `app_text_field.dart:9-24` | 加 `maxLines` / `minLines` / `maxLength` 三个参数(现在没有,已核实)。顺带可收敛 `post_compose_page.dart:575-585` 的裸 `TextField` | S |
| 12 | `_MediaGalleryPage``MediaGalleryPage` | `post_detail_page.dart:880-985` | **提为共享**(移到 `lib/core/widgets/media_gallery_page.dart` 并公开),行为零改 | S |
| 13 | `PostComposePage` | `post_compose_page.dart:39-46` | 加 `initialDraftPostId``_restoreLatestDraft`:194-220)改为「有 id 按 id 拉」;**`_draftMedia` 从计数文字(:620-638)改为 `PostMediaGrid` 渲染**;分类区支持 `ai_creation` 锁定态(现 `ChoiceChip×2`:589-605 | M |
| 14 | `MediaPurpose` | `community_models.dart:67-75` | 加 `aiInput('ai_input')` / `aiOutput('ai_output')`(零 Flyway,白名单是配置项) | S |
| 15 | `PostEntryPoint` | `post_analytics.dart:19-28` | 加 `aiResult`(埋点归因,口径交由埋点规格) | S |
| 16 | 呼吸动效逻辑 | `feed_skeleton.dart:26-46` | **不改 `FeedSkeleton` 本体**,但 `AiWorkGridSkeleton` 须复用同一套参数(1200 ms、0.6↔1.0、`disableAnimations` 静止在 1.0)。建议提为共享 `BreathingOpacity`,两处共用一份 | S |
### 4.3 必须新建(8
| # | 组件 | 建议位置 | 用途 | 规模 |
| --- | --- | --- | --- | --- |
| 17 | `AiHeroCard` | `features/create/` | A1 渐变 hero + 上传 CTA + 紧凑配额行 | S |
| 18 | `StyleCard` | `lib/core/widgets/style_card.dart` | 风格/模型卡,5 态(默认/选中/按下/不可用/无预览图) | M |
| 19 | `GenerationParamSection` | `features/create/` | 自绘渐进披露分组(**不用 `ExpansionTile`**,避免复制 `SegmentedButton` 粉底那笔债的成因) | S |
| 20 | `GenerationProgressPanel` | `features/create/` | A3 核心:不确定/确定环切换 + 阶段步进 + 已用时 + 僵死态 | **L** |
| 21 | `GenerationJobCard` | `lib/core/widgets/` | A5 网格单格,五 status 态 + 已发帖角标 | M |
| 22 | `QuotaMeter` | `lib/core/widgets/quota_meter.dart` | 三处共用(hero 紧凑行 / A2 底条 / S1 完整条),含耗尽态 | S |
| 23 | `ActiveJobBar` | `lib/core/widgets/active_job_bar.dart` | G1 全局条,三种文案态;**客户端首个跨页状态条** | M |
| 24 | `AiWorkGridSkeleton` | `lib/core/widgets/` | A5 首载 9 格骨架(`FeedSkeleton` 是单图卡形态,形状完全不同,不能直接用) | S |
### 4.4 不是组件、但必须一并落的三件事
| 事项 | 说明 | 规模 |
| --- | --- | --- |
| 客户端任务状态层 | 轮询(前台限定 + 退避阶梯)+ jobId 列表持久化 + 跨 Tab 单例 + 杀进程恢复。**客户端零先例**(§0.5 第 2 条),是本迭代最大的一块新建 | **L** |
| `Badge` 首次引入 | Material 内置组件,全 lib 零使用(已核实)。用在底栏创作 Tab 图标上(`main_shell_page.dart:277-281` | S |
| `segmentedButtonTheme` | 偿 §8.1 的债。不做也不阻塞 M4(M4 的分段器数据驱动后不渲染),但另 5 处仍在 | S |
### 4.5 现状 demo 的消亡清单(`create_page.dart`563 行)
| 现状 | 行号 | M4 处置 |
| --- | --- | --- |
| `_ComposeEntryCard` 真实发布入口 | :394-419 | **保留不动**T3-17 落地的真东西) |
| `SegmentedButton<CreationMode>` | :138-157 | 改数据驱动;M4 无 video 时不渲染 |
| `_UploadCard`(读 demo 宠物头像当"上传" | :421-493 | **消亡** → A2 的 `PostMediaEditGrid` 真上传 |
| 「生成设置」卡(模型下拉 / 时长 / 分辨率 / 高清增强) | :170-214 | 部分继承:模型下沉二级披露;分辨率→画面比例;高清增强保留;`_ChoiceRow`(:495-530)可就地重写为比例 chip 组 |
| 横滑风格列表(`SizedBox(height:150)` + 125 宽卡) | :218-292 | **消亡** → 正典 3 列网格 + `StyleCard`(正典 `.style-grid` :163-173;现状与正典不符) |
| `_GenerationProgress` 四步假阶梯 | :532-563 | **消亡**`GenerationProgressPanel` 三段(或两段)真状态 |
| `generate()` / `simulateUpload()` 假延时 | :55-92 | **消亡** |
| 结果卡(标题/正文/话题 `InputChip`/位置/发布钮) | :312-386 | **消亡** → A4 三动作 + 发布走 `PostComposePage` |
| `publish()` 占位 SnackBar | :122-129 | **消亡** |
| `CreationStyle` 模型 + `creationStyles` 常量 | `models.dart:285-297``demo_data.dart:206-233` | **消亡** → 服务端目录 |
| `tags` 本地话题数组 + `addTag()` AlertDialog | :46、:94-120 | **消亡**(话题在客户端不存在,ADR-018 已剪出) |
## 5. 中文文案表
**不给 i18n key**(§0.6b 已核实:仓库无 ARB / gen-l10n / `AppLocalizations`,业务文案是源码内硬编码中文)。Material 组件自带文案(对话框按钮、日期选择器等)交给三件套 delegate,**不在业务代码重复硬编码**(既有纪律 `app_date_picker.dart:75-76`)。
### 5.1 界面文案
| 落点 | 中文原文 | 备注 |
| --- | --- | --- |
| A1 hero 标题 | `变成毛孩子的写真` | 正典是`如果我的猫变成人?`(:328)——那是一句具体的玩法标题,不适合做常驻 hero 标题(每次进来都问同一个问题会腻)。改为能力陈述 |
| A1 hero 副标 | `上传一张照片,AI 帮你生成专属宠物写真` | 正典原文(:329)沿用 |
| A1 hero CTA | ` 上传宠物照片` | 正典原文(:330)。**若 D2 改为参考图可选,须改为**`开始创作` |
| A1 分区标题 | `热门风格` / `AI 视频` | 正典原文(:332、:341 |
| A1 历史钮 tooltip | `我的 AI 作品` | |
| A1 空态 | `暂时没有可用的风格` / `我们正在准备新的风格,稍后再来看看` | 无 CTA |
| A2 标题 | `生成设置` | |
| A2 参考图区标题 | `宠物照片` | |
| A2 已选风格行 | `已选风格:` + `#吉卜力` / `更换` | |
| A2 提示词标题 | `想让它变成什么样?(选填)` | |
| A2 提示词 hint | `例如:坐在樱花树下,暖阳,胶片质感` | 给具体例子,不写`请输入提示词` |
| A2 提示词软上限提示 | `再长也不会更准,试试精简一点` | 到 500 字时出现,不硬截断 |
| A2 宠物区标题 | `这是哪只毛孩子?(选填)` / `不指定` | |
| A2 一级披露 | `更多设置` | |
| A2 比例标题 | `画面比例` | 值:`1:1` `3:4` `4:3` `9:16` `16:9` |
| A2 高清增强 | `高清增强` / `提升毛发与眼睛细节` | 副标沿用现状 `create_page.dart:208` |
| A2 二级披露 | `高级` | |
| A2 负面提示词 | `不想出现的元素(选填)` | **不叫「负面提示词」**(专业术语,普通用户读不懂) |
| A2 模型标题 | `创作模型` | 仅目录 >1 个模型时渲染 |
| A2 提交钮 | `✨ 开始生成(消耗 1 次)` | 代价写在按钮上 |
| A2 提交中 | `正在提交…` | |
| A2 gating 原因行 | `先选一张宠物照片` / `先选一个风格` / `照片还在上传` | 只置灰不说原因是既有通病,M4 不复制 |
| A3 标题 | `正在生成` | |
| A3 排队 | `已排队` / `正在等待空闲的算力` | 有 `queuePosition` 时副行改 `前面还有 3 个任务` |
| A3 生成中 | `正在生成…` | |
| A3 已用时 | `已经等了 42 秒` / `已经等了 2 分 15 秒` | **不是**预计剩余时间 |
| A3 阶段步进 | `已排队` / `生成中` / `收尾` | 第三段仅在 progress 可信时渲染(D4 |
| A3 可离开说明 | `可以先去做别的,回到 App 就能看到结果` | ⚠ **绝不能写「完成后通知你」**(无推送能力,§2.3 |
| A3 僵死态 | `还在生成,比平时久一些` / `你可以继续等,或者取消这次生成` | **不是错误文案** |
| A3 轮询抖动 | `网络不太稳,正在重连…` | 连续 3 次失败才出现 |
| A3 取消钮 / 确认 | `取消` / `取消这次生成?` | 对话框内容按 D11`已消耗的额度不会退还``额度会退还给你` |
| A3 已取消终态 | `已取消这次生成` / CTA `再试一次` | |
| A4 标题 | `创作完成` | |
| A4 主 CTA | `发布到社区` | |
| A4 次要钮 | `重新生成` / `暂不发布` | **不叫「丢弃」/「删除」**(§2.4 |
| A4 重新生成代价 | `将消耗 1 次额度` | |
| A4 底部说明 | `作品已存在「我的 AI 作品」里,之后也能发布` | |
| A4 图加载失败 | `图片加载失败,下拉重试` | 三个动作钮仍可用 |
| A5 标题 | `我的 AI 作品` | 与正典菜单项一致(:497) |
| A5 格内字条 | `排队中` / `生成中` / `失败` / `已取消` | |
| A5 空态 | `还没有 AI 作品` / `上传一张宠物照片,试试把它变成写真` / CTA `开始创作` | |
| A5 长按 sheet | `保存到相册` / `删除` / `删除这个作品?` | 删除是真删(与 A4「暂不发布」区分) |
| A5 翻页失败 | `加载失败,点击重试` / `没有更多了` | |
| G1 条 | `正在生成你的作品…` / `2 个作品正在生成…` / `作品生成好了,来看看 →` | |
| G1 完成 SnackBar | `作品生成好了` + action `查看` | 不弹对话框 |
| S1 sheet | `今日额度已用完` / `每天可以免费生成 5 次,明天 0 点重置` | 次数与窗口须与服务端配额一致,不写死 |
| S1 出口 | `看看我的作品` / `去社区逛逛` | **不给「升级会员」**M4 无支付,D12 |
| 发布页分类锁定标签 | `AI 创作` | 静态 `TagPill`,不可改 |
| 发布页正文 hint | `说说这张图的灵感…` | |
| 发布页建议标签 | `#吉卜力风` / `提示词` / `我家豆豆` | 用户点了才插入正文,**不预填** |
| 发布页提示词开关 | `附上创作提示词` | 默认关(D9 |
### 5.2 错误文案分层
**⚠ 前提:以下错误码除 40405 / 42203 / 40902 外,其余全部尚不存在**,需在 M4 契约新开(§10 R4)。号段是**建议**,须与后端一起定号(纪律:业务码永不复用或改号,`openapi.yaml:28`)。
| 情形 | 建议码 | 用户可见文案 | 出口 |
| --- | --- | --- | --- |
| 配额耗尽 | 429 / `42900` | `今日额度已用完,明天 0 点重置` | S1 sheet |
| 频率过快 | 429 / `42901` | `操作太频繁,请 12 秒后再试` | SnackBar + 倒计时,**不弹 sheet** |
| 参考图不存在 / 非本人 / 用途不符 | 404 / `40405`**已存在** | `这张照片已失效,请重新选择` | 回 A2 参考图区 |
| 参考图非 ready | 422 / `42203`**已存在** | `照片还没处理完,稍等一下再试` | 停在 A2 |
| 风格 / 模型已下架 | 404 / 新码 | `这个风格暂时下架了,换一个试试` | 回 A1 |
| 提示词违规 | 422 / 新码 | `提示词里有不能生成的内容,改一改再试` | 停在 A2,聚焦提示词框 |
| 生成失败:识别不到宠物 | `error_code` 分支 | `参考图里没有识别到宠物,换一张试试` | A3 失败态「换个参考图」 |
| 生成失败:提供方不可用 | 503 / `50300`**已存在**)或 `error_code` | `生成服务暂时不可用,稍后再试` | A3 失败态「用同样设置重试」 |
| 生成失败:兜底 | 任意未识别 `error_code` | `这次生成没成功,换个参考图或稍后再试` | **绝不写「未知错误」**;折叠区可展开看 `error_message` |
| 重试次数已尽 | `attempt_count == max_attempts` | `同样的设置已经试过几次了,建议换张照片` | 「用同样设置重试」降为次要态 |
| 建草稿失败:写侧拒收分类 | 400 / `40000` | `发布准备失败,请重试` | 停在 A4(这是 §10 R5 未做时的表现,属实现缺陷不该到用户面前) |
| 草稿乐观锁冲突 | 409 / `40902`**已存在** | `草稿已被更新,请重新操作` | 沿用 M3.5 既有文案(`iteration-3.5/05 §1.2`),不另造 |
| 网络不可达 | — | `网络好像不太好,检查一下再试` | 沿既有网络层文案惯例 |
## 6. 动效与反馈约定
### 6.1 动效清单
| 场景 | 动效 | 时长 / 曲线 | reduce-motion 降级 |
| --- | --- | --- | --- |
| 骨架呼吸(A1/A5 | 不透明度 0.6 ↔ 1.0 循环 | 1200 ms 往复 | **静止在 1.0**(沿 `feed_skeleton.dart:40-43` |
| 渐进披露展开 | `AnimatedSize` | 200 ms `easeOut` | 瞬变(`duration: Duration.zero` |
| 风格卡选中 | 描边色 + 勾选角标淡入 | 150 ms | 瞬变 |
| 进度环(不确定) | Material 内置旋转 | 内置 | **不停转**(这是状态指示不是装饰,停了会让人以为卡死);改用 `LinearProgressIndicator` 不确定态亦可 |
| 进度环(不确定 → 确定切换) | 从当前角度续上,**不回零** | 300 ms `easeOut` | 直接跳到当前值 |
| 进度环补满 → 进 A4 | 补到 100% → 停 200 ms → `pushReplacement` | 合计 ≈500 ms | 直接跳转 |
| G1 条出现 / 消失 | 高度 + 不透明度 `AnimatedSize` | 200 ms `easeOut` | 瞬现 / 瞬隐 |
| G1 条「进行中 → 已完成」 | 底色与图标交叉淡入 | 250 ms | 瞬变 |
| Tab 角标出现 | `Badge` 内置 scale | 内置 | 瞬现 |
| 上传格进度层 | 沿既有 `UploadProgressOverlay`(成功态 150 ms 淡出,`upload_progress_overlay.dart:64-70` | 150 ms | 沿既有 |
| 页面转场 | 系统默认 `MaterialPageRoute` | 内置 | 系统处理 |
**reduce-motion 检测口径**:用 `MediaQuery.maybeOf(context)?.disableAnimations ?? false`**`maybeOf` + 兜底**,沿 `like_button.dart:104-105` 的稳妥写法,而不是 `feed_skeleton.dart:40``MediaQuery.of`)。
**唯一不降级的动效是进度环的旋转**,理由已写在表里。这是一个刻意的偏离,须在源码注释里记明(否则下一个人会"顺手统一"掉)。
### 6.2 反馈约定
| 反馈层 | 用在哪 | 不用在哪 |
| --- | --- | --- |
| 字段级 `errorText` | 提示词违规、软上限提示 | 不用于网络错误 |
| 区块 `InlineErrorBanner` + 重试 | A1 目录失败、A2 提交失败、A4 建草稿失败、A5 首载失败 | 不用于轮询抖动(§2.3:抖动不升级为错误) |
| 瞬态 `SnackBar` | 频率过快(带倒计时)、G1 完成通知、删除成功 | **不用于配额耗尽**(那要给出口,用 S1 sheet) |
| Bottom sheet | S1 配额说明、A5 长按操作 | 不用于 A2 表单(要与其他项同屏) |
| `AlertDialog` | 取消生成确认、删除作品确认 | **不用于「作品生成好了」**(抢焦点,用户可能正在打字) |
| 页内终态版式 | A3 失败态、A3 取消终态 | — |
**提交类操作的防重复三道闸**(AI 生成是花钱的,比点赞严格得多):
1. 点击后立即 `AbsorbPointer` 整页 + 钮内转圈(视觉与交互双锁)
2. 幂等键:沿既有纪律(`post_compose_page.dart:98``:179-182` 表单一改即换新键),`generation_jobs``UNIQUE(user_id, idempotency_key)`(设计稿 `:643`
3. **A4「重新生成」必须换新幂等键**,否则会命中同一个旧 job,用户看到「点了没反应」
**乐观更新在 M4 不适用**。M3 的点赞/收藏乐观更新(`iteration-3/05 §4`)是因为结果可预测;AI 生成的结果**不可预测**,不存在可乐观呈现的终态。M4 的所有写操作都是「等服务端确认」。这条要写明,避免有人照搬 M3 的策略。
## 7. 无障碍要点与色彩自查
### 7.1 无障碍要点
**既有惯例(已核实,M4 沿用不新造)**
- `IconButton` 一律给中文 `tooltip`(全 lib 13 处,如 `main_shell_page.dart:253``create_page.dart:463``post_detail_page.dart:954`
-`InkWell` / `GestureDetector``Semantics(label:, button:)`,且 `button:` 随可用性变化(`like_button.dart:159-161``button: widget.onPressed != null` 是最规范的写法)
- 触控目标 ≥44×44;不足时在源码显式记录妥协(`comment_tile.dart:114-115``post_media_grid.dart:226`
- 共享组件把语义 label 做成参数(`LikeButton.semanticLabel`),由调用方给业务语义
**M4 必须补的语义标注**(这些是纯图形/纯状态元素,不标注则屏幕阅读器读不出):
| 元素 | 要求 |
| --- | --- |
| `StyleCard` | `Semantics(label: '<风格名>风格', selected: <是否选中>, button: true)`。**必须给 `selected`** —— 选中态是描边 + 角标(纯视觉),不给 `selected` 屏幕阅读器无法区分 |
| `GenerationProgressPanel` | 整块 `Semantics(label:)` 动态描述当前状态(如`正在生成,已经等了 42 秒`+ `liveRegion: true`(状态变化时主动播报)。**这是全仓第一处 `liveRegion` 用法**,需在注释里说明理由 |
| 进度环 | `ExcludeSemantics`(数值已由外层 label 承载,避免重复播报"进度条")。注:`ExcludeSemantics` 全 lib 零使用,M4 首例 |
| `GenerationJobCard` | `Semantics(label: '<状态>的作品,<发布状态>', button: true)`;纯图形态(`queued`/`running`/`failed`/`cancelled`)尤其必要 |
| `ActiveJobBar` | `Semantics(label: <条文案>, button: true, liveRegion: true)`(完成时播报) |
| Tab 角标 | Material `Badge` 需给 `Badge(label:)` 或外层 `Semantics` 说明`有正在生成的作品`;**纯小圆点无数字,屏幕阅读器读不到任何东西** |
| `QuotaMeter` | `Semantics(label: '今日还可生成 3 次,共 5 次')`;进度条本身 `ExcludeSemantics` |
| A4 结果图 | `Semantics(image: true, label: '<风格名>风格的生成结果,双击查看大图')` |
**其他要点**
- **字体放大适配**`textScaler` / `textScaleFactor` 全 lib **零处理**(已核实)。M4 的 `StyleCard` 底部字条(12/w700 在 `1/1.1` 比例的格内)与 `GenerationJobCard` 字条是最容易在 200% 缩放下溢出的两处 → 底部字条用 `maxLines: 1` + `TextOverflow.ellipsis`,且格高随字号增长(不用固定高度)。这是设计侧能做的防御;系统性的字体放大适配不在 M4 范围,记入遗留。
- **键盘 / 焦点**:A2 有 5+ 个可聚焦元素 + 两级披露。披露区展开后须把焦点送进新出现的第一个控件;收起时焦点回披露行本身(否则焦点会落在已隐藏的控件上)。
- **不靠颜色单通道传达状态**`StyleCard` 选中 = 描边 + 勾选角标(双通道);`GenerationJobCard` 状态 = 图标 + 文字字条(双通道);G1 完成态 = 底色 + 图标 + 文案(三通道)。
### 7.2 色彩无障碍自查(程序精算)
计算方法沿 `iteration-3/05 §5`WCAG 2.x 相对亮度公式;8% 淡底按 `withAlpha(20)`7.84%)与白底合成;scrim 合成按**最不利底(纯白图)**计算。阈值:正文 4.5:1,大字 3:1,非文字 3:1。
| 组合(用途) | 对比度 | 判定 |
| --- | --- | --- |
| `ink` / `surface`(正文、页码胶囊白字于 ink 实底同值 13.50 | 13.50 | 达标 |
| `inkSoft` / `surface` / `canvas` / `surfaceTint`(全部次级信息文字) | 6.59 / 6.21 / 5.58 | 达标 |
| 白字 / `ink` 80% scrim 合成白图(格内字条、+N 角标、已发帖角标) | 7.10 | 达标 |
| 白字 / `ink` 75% scrim**正典 `.style-card` label 原值**:171 | 6.11 | 达标,但**统一提到 80%** 与 M3 规则一致 |
| 白字 / `ink` 60% scrim | 3.88 | **不达标,禁用** |
| **白字 / `brandGradient` 中点** | **2.22** | **❌ 不达标 →** 见 D9 |
| **白字 / `brandGradient` accent 端(最不利)** | **1.74** | **❌ 严重不达标** |
| **`ink` 字 / `brandGradient` primary 端(最不利)** | **4.90** | **✅ 采纳方案**(中点 6.08accent 端 7.74 |
| `primaryDark` / 白 92% 叠渐变(正典 `.upload-btn`,最不利端) | 8.69 | 达标(正典这条是对的) |
| 白字 / `primaryStrong`(A4 主 CTA、发布钮、勾选角标底) | 4.49 | 达标(一迭代已裁决按 ≈4.5 采纳) |
| `primaryStrong` / `surface`(白卡内链接字、A5 翻页重试) | 4.49 | 达标 |
| `primaryStrong` / `canvas` | 4.23 | **贴线不过作正文**;仅可作**非文字**(描边/值条,≥3)。canvas 底文字一律 `primaryDark`8.88 |
| `primaryDark` / `surfaceTint`(G1 条文字、tonal 元素) | 7.98 | 达标 |
| `primaryDark` / primary 8% 底(`TagPill(primary)` | 8.74 | 达标 |
| **`primaryStrong` 值条 / `surfaceTint` 轨道**`QuotaMeter`、进度环) | **3.80** | 达标(非文字 ≥3 |
| **`primary` 值条 / `surfaceTint` 轨道** | **2.33** | **❌ 禁用** —— 值条必须 `primaryStrong` |
| **`primary` 2px 描边 / `canvas`**(现 `create_page.dart:237` 选中风格卡) | **2.59** | **❌ 不达非文字 3:1 →** D10 修订为 `primaryStrong`4.23 |
| **`success` 图标 / `surface`**(现 `create_page.dart:320``:553` | **2.67** | **❌ 不达非文字 3:1 →** D10 修订为 `successInk`7.90 |
| **`success` / `successSurface`** | **2.29** | **❌ 同上禁用** |
| `successInk` / `surface` / `successSurface`(G1 完成态图标与文字) | 7.90 / 6.79 | 达标 |
| **`warning` / `surface`** | **2.15** | **`warning` 不可作图标或文字色**(任何底上都不行:canvas 2.02)。僵死态用 `accentDark`7.40)或 `accent` 仅作**装饰底** |
| `accentDark` / `surface` / `canvas` / accent 8% 底 | 7.40 / 6.97 / 7.07 | 达标 |
| `accentDark` / `accent` 实底(正典 PRO 徽章语言) | 4.24 | 达标(正文 ≈4.5 贴线;仅用于 ≥14/w700 的短标签) |
| `errorDark` / `surface` / error 8% 底(失败图标、字条、耗尽态) | 6.50 / 5.78 | 达标 |
| `error` / `surface`(失败图标,非文字) | 4.99 | 达标 |
| 白字 / `errorDark` 实底(失败通栏「重试」) | 6.50 | 达标 |
| `muted` / `surface`hint、禁用、`cancelled` 图标) | 3.36 | **仅限占位/禁用/纯装饰**DEBT-2 允许的用途) |
| `surfaceTint` / `surface`(骨架块、格底) | 1.18 | 装饰性占位,不受约束 |
| **现状:SegmentedButton 选中 `onSecondaryContainer` #5D4038 / `secondaryContainer` #FFDAD2** | **7.19** | **对比度达标** —— 这笔债是**品牌一致性**问题不是无障碍问题(§8.1) |
**新增裁决点**
| # | 事项 | 处置 |
| --- | --- | --- |
| 1 | `brandGradient` 承载文字 | **一律用 `ink`,禁用白字**(白字最不利端 1.74:1)。M4 首次在渐变上放文字,此前 `brandGradient` 只用于装饰(头像环、`BrandMark` 方块)——所以这不是修旧债,是**立新规**:`brandGradient` 上的文字色恒为 `ink`;需要"反白"观感时改用 `primaryStrong → accentDark` 渐变(白字最差端 4.49:1),但那是另一套色,需单独拍板 |
| 2 | 次级信息不用透明度降权 | hero 副标不用「白/`ink` 92%」,靠字号 + 字重(12/w500 vs 18/w800)区分层级。透明度降权是 M3 已踩过的坑(`Colors.white70` 于 ink 80% scrim 实测仅 4.46:1 贴线不过,现 `create_page.dart:280` 正是此写法) |
| 3 | 进度值条与轨道 | 值条恒 `primaryStrong`,轨道恒 `surfaceTint`3.80)。`primary` 值条禁用 |
| 4 | 语义色的「图标可用性」 | `success`2.67)与 `warning`2.15**都不达非文字 3:1**,二者**只能作淡底**,图标/文字必须用 `successInk`7.90/ `accentDark`(7.40)。这条应作为全 app 规则登记(不止 M4) |
## 8. 设计债处置意见
### 8.1 SegmentedButton 粉底(M2 遗留)
**核实结论**(两路独立复核):
- `app_theme.dart` **没有 `segmentedButtonTheme`**`buildAppTheme()` 只定制 8 个子主题::105/124/133/146/173/179/180);全 `lib/` grep `segmentedButtonTheme|SegmentedButtonThemeData` **零命中**
- Flutter SDK(本机 3.44.6)默认:选中容器取 `colorScheme.secondaryContainer`、前景取 `onSecondaryContainer``flutter/packages/flutter/lib/src/material/segmented_button.dart:1214``:1224-1232`
- `app_theme.dart:88-92``fromSeed` 只 override 了 `surface`/`error`/`onError`**没 override `secondaryContainer`**
- **精确色值**(本机 `material_color_utilities 0.13.0` + `SchemeTonalSpot(#FF6F4C)` 实算):`secondaryContainer` = **`#FFDAD2`**、`onSecondaryContainer` = **`#5D4038`**、未选中描边 `outline` = `#85736F`
- **对比度 7.19:1,达标**
**处置意见:偿,但明确它是 P2 品牌一致性债,不是 P1 无障碍债。**
这一点很重要,因为它改变了优先级判断。`#FFDAD2` 是一枚淡**粉**(与色板的 `surfaceTint #FFE8D6` 桃色仅差 8 点蓝通道,肉眼是"一个偏粉一个偏橙"),它不在色板里,但**它不伤可读性**。所以:
- **不应该**为它拖延 M4 的功能范围
- **应该**在 M4 期间顺手补 `segmentedButtonTheme`:选中 `surfaceTint` 底 + `primaryDark` 字/w7007.98:1,与筛选 chip 同款,`iteration-2/05 §D7` 已裁决过的色对);未选中 `surface` 底 + `border` 描边 + `inkSoft` 字(6.59:1);描边从 `outline #85736F``border #F0DCC8`
- **收益面**:一处主题修好 **6 个使用点**(全部已核实无局部 `style:` 覆盖)—— `home_page.dart:385``create_page.dart:138``services_page.dart:102``pet_form_page.dart:426``pet_form_page.dart:457``vaccination_form_page.dart:340`
- **M4 自身的关系**`create_page.dart:138` 那一枚在 M4 会因"数据驱动 + 只有 image 类不渲染"而消失(§2.1),所以**M4 不修也不会有新债**;但另 5 处照旧。规模 S,建议并入 M4 的主题收敛小工单。
**连带诉求:同时补 `expansionTileTheme` 或干脆不用 `ExpansionTile`。** 本规范选后者(§2.2)—— `ExpansionTile` 的色值同样走 `ColorScheme` 派生,直接用会**在 M4 里现制一笔一模一样的新债**。这是这笔旧债给出的最有价值的教训:**凡 Material 组件的默认色未在 `app_theme.dart` 里被显式 override,就不要在新页面首次引入它。**
### 8.2 月份网格选择器缺失(M3.5 遗留)
**核实结论**`app_date_picker.dart` 全文 144 行,只提供 `dateOnly` / `today()` / `isDateSelectable` / `pickAppDate`(内部 `showDatePicker`:54-79/ `AppDateFieldTrailing`(:86-144)。**没有任何「只选年月」能力**。全 lib grep `showMonthPicker|MonthPicker|monthGrid|月份网格|selectMonth|YearMonth` 唯一命中是 `app_date_picker.dart:5` 那句注释本身。M3.5 的登记原文:`iteration-3.5/02-client-ux-fixes.md:282-283`「未做:月份网格选择器(需自绘/引包)。若第二批有余量可评估,但当前年份网格 + 手输 +「今天」已覆盖实测暴露的全部痛点」。
**处置意见:M4 不偿,且 M4 应主动设计成不触发它。**
- M4 唯一可能撞上它的地方是 A5 的「按月筛选我的作品」。**本规范建议 M4 不做时间筛选**(§2.5):M4 的作品量级不足以需要筛选,游标分页够用。
- 因此这笔债在 M4 **既不偿也不恶化**
- 若产品坚持要时间筛选(拍板 D15),成本从"零"跳到"自绘一个月份网格(M)",且它会成为第一个进 `lib/core/widgets/` 的日期类新组件 —— 那时应作为独立工单,不要挂在 AI 创作的工单里做。
### 8.3 ★ widthPx / heightPx 恒 null —— 在 M4 会不会变成显性 bug
**核实到的事实链(三层,逐层带证据)**
| 层 | 事实 |
| --- | --- |
| DB | `media.assets` 建表**有列**`V1__identity_media_baseline.sql:217-218` `width_px integer` / `height_px integer`CHECK **明确允许 NULL**:241-245 |
| 服务端 | main 代码对 `media.assets` 的三条写 SQL **全都不含这两列**`MediaAssetRepository.java:35-41`insertUploading)、`:82-86`markReady)、`:94-97`markFailed)。`MediaService.completeUpload`:80-119)只做 `storage.stat()` HEAD 校验,**无任何图片解码**;全仓 `ImageIO`/`BufferedImage` grep = 0。`toResponse`:121-128)原样透传库里的 NULL |
| 契约 | `openapi.yaml:3533-3539` `MediaAsset.widthPx` 的 description 写着「**complete 后回填,可空**」—— **与实现矛盾**(实际从不回填);`PostMediaItem.widthPx/heightPx`:3605-3610)连 description 都没有。两处均 `nullable: true`,均不在 `required` 里 |
| 客户端 | **详情页读比例**`post_detail_page.dart:513-521`null 时回落 `4/3`,非 null 时钳制 `(width/height).clamp(1/1.33, 1/0.75)` = `[0.752, 1.333]`。**Feed 卡根本不读**`post_card.dart:70-73` 硬编码 `AspectRatio(4/3)``PostMediaGrid` 单图折叠同样硬编码 4:3`post_media_grid.dart:326-327` |
| 数据通路 | `FeedCard.coverImage``PostMediaItem?``community_models.dart:383` 附近字段清单),**本来就带 widthPx/heightPx** —— 通路是通的,是卡片自己忽略了 |
**严重度判断:中高(P1),M4 应作为必做项,理由不是"更严重了"而是"从看不见变成看得见"。**
M3 的裁切用户容忍度高,因为那是**用户自己拍的照片**——他知道原图长什么样,也知道 App 只是裁了个封面。M4 完全不同:
1. **AI 生成图的比例是用户自己在 A2 里选的**1:1 / 3:4 / 9:16 …),他对"我要的是竖图"有明确预期
2. **他刚在 A4 看过完整构图**A4 用 job 的 width/height,比例正确,§2.4
3. **然后同一张图在 Feed 里被裁成 4:3** —— 这是**同一次会话内的前后直接对照**。用户的结论不会是"Feed 封面裁切策略",而是"发布把我的图裁坏了"
具体损失量(按 A2 提供的 5 档比例算):
| 用户选的比例 | Feed 卡(恒 4:3 ≈1.333 | 详情页(widthPx null → 也回落 4:3 |
| --- | --- | --- |
| 1:1 (1.0) | 上下各裁 ~12.5%,主体居中时可接受 | 同 |
| 4:3 (1.333) | 正好 | 正好 |
| 3:4 (0.75) | **裁掉约 44%** | 同(钳制下界 0.752,几乎正好,但前提是有值) |
| 16:9 (1.778) | 左右各裁 ~12.5% | 同 |
| **9:16 (0.5625)** | **裁掉约 58%,显性 bug 级** | **即使有值也被钳到 0.752,仍裁掉约 25%** |
**这里有一个几乎零成本的修法,是本节最重要的结论:AI 输出路径不需要图片解码。**
`generation_jobs.width_px` / `height_px``patbond_postgresql.sql:616-617`)就是**生成尺寸本身**——worker 在写 output asset 时这两个数就在手里。所以:
- **用户上传路径**要回填尺寸,确实需要引入图片解码(`ImageIO` 或探测库),那是有成本的(也是 `iteration-3/28-e2e-smoke-report.md:661-662` 建议的两个选项之一)
- **AI 输出路径**只需要 worker 在 insert output asset 时把 job 的两个数一并写进去。**一行赋值,零新依赖。**
**设计侧的三项诉求(缺一不可,只做第一项修不好)**
| # | 诉求 | 落点 | 规模 |
| --- | --- | --- | --- |
| R1a | **worker 写 AI 输出 asset 时回填 `width_px`/`height_px`**(取 job 的生成尺寸,无需解码) | 后端 creation worker | S |
| R1b | **`post_card.dart:70-73` 单图改读 `coverImage.widthPx/heightPx`**,null 时才回落 4:3。数据通路本来就通(`FeedCard.coverImage``PostMediaItem` | 客户端 | S |
| R1c | **详情页钳制下界从 `1/1.33`0.752)放宽到 `9/16`0.5625**`post_detail_page.dart:520` | 客户端 | S |
R1c 的理由:M3 定这个钳制是为了防"用户上传的超长截图撑爆列表"(合理)。但 **AI 生成图的比例是枚举可控的**(用户只能从 5 档里选),放宽到 9:16 仍能防住无限长图,同时让竖版 AI 图正确显示。若担心影响普通帖,可按 `category == 'ai_creation'` 分档钳制 —— 但更简单的做法是统一放宽到 9:16(普通用户上传的手机竖拍照片是 3:4,本来就在范围内;真正超长的截图仍被钳住)。
**若三项一个都不做的后果**:AI 创作是 M4 的招牌功能,而它产出的作品在社区里**一律显示为被裁坏的 4:3**。这是我在本规范里唯一标记为"会直接损害功能可信度"的债。
**另一个应当顺手做的事**`openapi.yaml:3533-3536` 那句「complete 后回填,可空」是**错的描述**(实现从不回填)。要么随 R1a 让它变成真的(AI 路径变真了,用户上传路径仍假),要么改成「预留字段;AI 输出回填,用户上传暂不回填」。**留着一句和实现矛盾的 description 是最坏的选项** —— 下一个读契约的人会照它写代码。
### 8.4 首页 demo 占位与 AI 入口的共存
**核实结论**:首页 `ListView` children 顺序与 demo 登记(`home_page.dart:23-33` 是 ADR-022 钉死的登记表):天气条(:361-365demo)→ 问候卡(:367-375,问候名真实、右侧大图与建议仍 demo)→ 搜索框(:377-384demo)→ `SegmentedButton`:385-401,真)→ `_StoryRow`(:404,圈子是 demo,环内「发布」是真入口)→ `_PromoCard`:406demo)→ Feed:408,真)。且 `home_greeting_test.dart` 有**反向用例钉住「保留项仍在」**。
**处置意见:M4 不给首页加任何新的 AI 入口卡片。**
- 底栏创作 Tab`main_shell_page.dart:277-281`,✨ `auto_awesome`)已是正典给的**一级入口**`AI宠物_iOS_UI设计稿.html:350` tabbar 就是这么画的)。首页再加一张"去 AI 创作"卡是重复导航。
- 更实际的顾虑:首页已经有一张促销形态的大卡(`_PromoCard`)。再放一张渐变 hero 卡在它附近,**两张争视觉重量的卡上下相邻**是最糟的布局结果,而且 `_PromoCard` 是刻意保留的 demo(不能移走它来腾位)。
- **唯一放进首页的东西是 G1 状态条,位置在页面第一位(天气条之上)。** 它不与 ADR-022 冲突:ADR-022 钉的是「三项 demo 刻意保留、不得顺手清理」(`decisions.md:188`),G1 是**在其上方插入**,不移动、不删除、不改动任何 demo → `home_greeting_test.dart` 的反向用例不会挂。且它只在有在途任务时渲染,无任务时零占位。
- **若产品坚持要首页曝光 AI 创作**(拍板 D16):唯一不冲突的插入位是**问候卡与搜索框之间**(`home_page.dart:375` 之后、`:377` 之前),形态必须是**窄条 pill 入口**(高 ≤52,一行文字 + `chevron_right`)而不是大卡。理由同上。
### 8.5 顺手核出的三处现状缺陷(`create_page.dart`M4 替换时一并纠正)
这三处都在 M4 的替换范围内,不是额外工作量,但值得写明避免照抄:
| # | 位置 | 问题 | 修法 |
| --- | --- | --- | --- |
| 1 | `create_page.dart:237` | 选中风格卡描边用 `AppColors.primary`,于 `canvas`**2.59:1**,不达非文字 3:1 | 改 `primaryStrong`(4.23)+ 加勾选角标(双通道) |
| 2 | `create_page.dart:320``:552-554` | `Icons.check_circle` / 阶段勾选用 `AppColors.success`,于白卡 **2.67:1**,不达非文字 3:1 | 改 `successInk`(7.90)。**这条应升为全 app 规则**:`success``warning` 只能作淡底,不能作图标/文字(§7.2 裁决点 4) |
| 3 | `create_page.dart:280` | 风格卡副标用 `Colors.white70`,于 `ink` 80% scrim 实测 **4.46:1**,贴线不过 | 改纯白(7.10);层级靠字号 10 vs 12/w700 区分,不靠透明度 |
另两处**不是缺陷但偏离色板**,M4 替换时一并收敛:`create_page.dart:298` 主 CTA 用 `backgroundColor: AppColors.ink`(白/ink 13.50:1 达标,但主按钮主题本是 `primaryStrong``app_theme.dart:135`);`create_page.dart:545` `LinearProgressIndicator` 未指定色,走 `colorScheme.primary` 的 seed 派生值(实算 `#904B3A`)而非色板成员。
## 9. 待拍板决策清单(18 项)
标 ★ 的三项是最关键的(不定这三项,A2/A3 无法开工)。
| # | 事项 | 推荐 | 理由 |
| --- | --- | --- | --- |
| **★ D1** | **长耗时等待态的整体形态** | 提交后 `pushReplacement` 进 A3A3 **明确允许离开**;进度用「阶段文字 + 不确定环」为主,`progress>0` 才叠确定型数字;**不展示 ETA,改展示已用时**;回来的路三条(G1 条 → Tab 角标 → A5);**文案不得承诺推送** | 三条方案里只有这条既给出「事情真的开始了」的即时确认,又不把用户锁死。ETA 需要提供方支持 + 队列深度模型,M4 两者都无(AI 提供方 ADR 尚未产生),倒计时走完还没完成是最伤信任的反模式。「通知你」在无推送能力(已核实无 WS/SSE/FCM)下是直接的失信 |
| **★ D2** | **参考图必填还是可选** | **必填** | 工单描述写「可选参考图」,但设计稿 `patbond_postgresql.sql:612``input_asset_id uuid NOT NULL`,正典也是「上传一张照片,AI 帮你生成专属宠物写真」(:329)——产品定位是**图生图(宠物写真)**而非通用文生图。若改可选,A1 hero CTA 文案、A2 gating、以及"纯提示词能不能生成"三处都要改(§2.2 已备降级形态) |
| **★ D3** | **配额契约(429 + 两个新错误码)** | 新开 429,并**拆成两个码**:`42900` 配额耗尽 / `42901` 频率过快 | 429 与配额码**当前完全不存在**(`ErrorCode.java:10-38` 27 个码里没有;`feature-checklist.md:194` 记为 ⬜)。两类必须分开:配额耗尽等的是**明天**(该给出口,弹 S1 sheet),频率过快等的是**几秒**(该原地待着,弹 SnackBar),共用一个码就必然有一半场景的文案和出口是错的。号段须与后端一起定(业务码永不改号,`openapi.yaml:28` |
| D4 | `running``progress` 是否保证 >0 且单调递增 | **不保证** → UI 恒以不确定环为主形态,阶段步进**只做两段**(已排队 / 生成中),第三段「收尾」不渲染 | 设计稿 `running` 态对 progress **无下限约束**`:673-677`);真实进度取决于尚未选定的提供方。押注"会有进度"的后果是最常见观感变成「0% 卡住」 |
| D5 | 画面比例:枚举 5 档,还是自由像素 | **枚举 5 档**1:1 / 3:4 / 4:3 / 9:16 / 16:9),客户端按「模型基准边长 × 比例」换算像素 | `width_px`/`height_px` 是 64..8192 自由整数,但让用户填像素是灾难。**连带诉求**:目录端点须给每个模型的支持比例白名单 + 基准边长(§10 R2),否则客户端会算出提供方不支持的尺寸 |
| D6 | AI 视频(`media_kind = video`)在 M4 的呈现 | **数据驱动,M4 不渲染 kind 切换器**(正典的「AI 视频」分组整段不出现) | ADR-018 视频后置,契约 `kind` enum 只有 `image``openapi.yaml:3456-3457`)。渲染一个永远点不动的「AI 视频」段是在承诺不存在的能力。数据驱动后契约扩到 video 时 UI 自动长出来 |
| D7 | 目录端点是否返回 `enabled=false` 的风格/模型 | **只返回 enabled 的**(服务端已有 partial index `WHERE enabled``:601-603`) | 返回全量会让 UI 多维护一个灰态,而"暂不可用的风格"对用户没有信息价值 |
| D8 | 提示词长度:UI 是否设软上限 | **软上限 500**(计数器 `N/500`,到顶给提示但不硬截断;硬上限仍是契约 10000) | 给用户看 `0/10000` 会鼓励写小作文,多数提供方对超长 prompt 效果反而更差。不硬截断是为了不吃掉用户粘贴的长文 |
| D9 | 发帖时是否公开创作提示词 | **默认不公开**,给一个「附上创作提示词」开关(默认关) | prompt 可能含隐私(他人姓名、地点、私人梗)。默认公开等于替用户做了一个不可撤回的隐私决定 |
| D10 | `brandGradient` 上的文字色(**新规则** | **恒 `ink`,禁用白字** | 白字于该渐变最不利端 **1.74:1**、中点 **2.22:1**,严重不达标;`ink` 最不利端 4.90:1 达标。此前 `brandGradient` 只用于装饰(头像环、`BrandMark`),M4 是首次在其上放文字,所以这是**立新规**不是修旧债。需要"反白"观感时改用 `primaryStrong → accentDark` 渐变(白字最差 4.49:1),但那是另一套色,须单独拍板 |
| D11 | 生成失败 / 用户取消,额度是否退还 | **失败退还,用户主动取消不退还** | 失败是服务方的责任,扣额度会被视为坑钱;主动取消是用户的选择,且 worker 可能已经消耗了算力。**这条必须与文案严格一致**(§5.1 的 A3 取消对话框与失败态说明都依赖它),说反方向会直接被判为欺骗 |
| D12 | 配额耗尽时是否给「升级会员」入口 | **不给** | 正典有会员卡「AI 会员 Plus / 无限 AI 生图」(:489-493),但 M4 **无任何支付能力**(优惠/订单在 M5)。给一个通向死路的升级按钮比不给更伤。M4 只给两个真能去的出口(看作品 / 逛社区) |
| D13 | A5 是否做「保存到相册」 | 建议做,但**依赖需批** | 需引入 `gal` / `image_gallery_saver` 类新依赖 + Android/iOS 权限声明。**未取证**该依赖是否可接受。若不批,A5 长按 sheet 只留「删除」 |
| D14 | A4 是否给系统分享(分享到微信等) | 建议 M4 **不做** | 需 `share_plus` 依赖 + 先把签名 URL 的图落到临时文件(预签名 URL 直接分享出去会在 1 h 后失效,接收方点开是 403)。收益不如成本,留 backlog |
| D15 | A5 是否做时间/月份筛选 | **不做** | 做了就触发 §8.2 的月份网格缺失(成本从零跳到 M)。M4 作品量级不足以需要筛选,游标分页够用 |
| D16 | 首页是否加 AI 创作入口 | **不加**(只放 G1 状态条在页面第一位) | 底栏创作 Tab 已是正典给的一级入口,首页再加是重复导航;且首页已有 `_PromoCard` 这张促销形态大卡(刻意保留的 demo,不能移走腾位),两张争视觉重量的卡相邻是最糟布局。若坚持要,唯一不冲突的位置与形态见 §8.4 |
| D17 | 一键建草稿走服务端还是客户端预填 | **服务端建草稿**,客户端只带 `draftPostId` 进发布页 | 复用了发布页已有的草稿恢复通路(`post_compose_page.dart:194-220` 本来就会恢复 `draft.media`),改动面最小;且把「AI 帖元数据挂接」收在服务端一处。**连带必做**:修 `post_compose_page.dart:620-638`(草稿图现在只显示计数文字不渲染缩略图,对 M4 是明确缺陷) |
| D18 | 是否允许同时排多个生成任务 | **允许**,上限由配额自然约束 | A1 在有在途任务时仍可提交新任务。若要限制"同时只能一个"A1 的 hero CTA 需在有在途任务时禁用,且要给出原因文案——那是额外的一套状态,收益不明 |
**跨角色需确认的三项**(不是纯 UI 决策,但会改变界面):
| # | 事项 | 需谁确认 |
| --- | --- | --- |
| X1 | `queuePosition`(队列排位)能否下发 | 后端。给不出则 A3 排队态只能说「已排队」(§10 R3) |
| X2 | 僵死态阈值(本规范暂取 10 min) | 后端 + 产品。需要真实生成耗时量级才能定,而那取决于未产生的提供方 ADR |
| X3 | G1 完成态「点过才消失」的埋点口径 | 埋点侧。涉及「结果已被查看」这个客户端状态是否要上报 |
## 10. 给后端 / 契约的设计侧诉求
按「界面画不出来的程度」排序。R1 是唯一标记为「不做会直接损害功能可信度」的。
| # | 诉求 | 为什么界面需要它 | 规模 |
| --- | --- | --- | --- |
| **R1** | **AI 输出 asset 回填 `width_px`/`height_px`**(取 job 的生成尺寸,**无需图片解码**);配套客户端 R1b(`post_card.dart:70-73` 单图读比例)+ R1c(详情页钳制下界放宽到 9/16) | 否则 M4 的招牌功能产出的作品在社区里一律显示为被裁坏的 4:3,而用户刚在 A4 看过完整构图 —— 同一次会话内的直接对照(§8.3 全文) | S(三处各 S) |
| **R2** | **目录端点给出每个模型的支持比例白名单 + 基准边长** | A2 的画面比例是枚举 chip,客户端要按「基准边长 × 比例」换算像素。没有白名单,客户端会算出提供方不支持的尺寸,提交必被拒(D5) | S |
| **R3** | **任务状态响应带 `queuePosition`**`queued` 时) | A3 排队态的唯一可信信息。给不出就只能说「已排队」——用户无法判断是 3 秒还是 3 分钟。**排位是离散事实,不是预测**,与 ETA 性质不同,可以安全展示(X1) | S |
| **R4** | **429 + 两个新错误码(配额耗尽 / 频率过快),且响应体带 `limit` / `remaining` / `window` / `resetAt`(或 `retryAfterSeconds`** | `QuotaMeter` 要在**提交前**显示剩余次数(预防优于事后报错);S1 要说出准确的重置时间;「频率过快」要显示倒计时。四个字段少一个都得靠客户端猜(D3) | M |
| **R5** | **放开写侧 `ai_creation` 分类**`CreatePostRequest.java:26``@Pattern(regexp = "general|help")` | 现在提交 `ai_creation` 直接 400/40000,「一键建草稿」整条链路走不通。DB CHECK 早已允许(`V5__community_baseline.sql:68`),只是写侧 DTO 挡着 | S |
| **R6** | **`POST /posts` 接受 `generationJobId`**`Post` / `FeedCard` 响应可选带回 | A5 要显示「这个作品已发过帖」的角标(需要 job→post 的关联);A4 复访时要知道是否已发布 | S |
| **R7** | **风格目录的内容从哪来**(运营后台?种子数据?) | `generation_styles.preview_asset_id` 是 nullable`:586`),没有预览图的风格卡只能显示图标占位。若上线时目录是空的或全无预览图,A1 就是一屏灰格子。**本项未取证**,需要一个答案 | ? |
| R8 | 任务列表端点沿用游标分页正典 `{items, nextCursor, hasMore}` | A5 的触底加载与「没有更多了」直接复用 M3 的既有骨架(`openapi.yaml:133-134` | S |
| R9 | **修 `openapi.yaml:3533-3536` 那句与实现矛盾的 description**(「complete 后回填,可空」实际从不回填) | 留着一句和实现矛盾的契约描述是最坏选项——下一个人会照它写代码。随 R1a 改为「AI 输出回填;用户上传暂不回填」 | S |
| R10 | 取消端点须在 `queued``running` 两态都可用 | A3 的取消入口在两态都渲染。设计稿的 `cancelled` 态对 output/error 无约束(`:687-691`),两处都能进 | S |
**明确不需要后端做的事**(避免过度设计):
- **不需要推送 / WebSocket / SSE。** 轮询 + G1 + 角标已经能覆盖「离开再回来」的全部路径(§2.3)。为 M4 引入推送基础设施的收益远低于成本。
- **不需要 ETA 字段。** 本规范主动不展示预计剩余时间(D1),所以不要为此建模。
- **不需要 job → N assets 的关系表。** M4 恒单图(`output_asset_id` 单列已够,§2.4)。
## 11. 交付验收对照(供开发 / QA)
- [ ] 8 个新建组件落位(`AiHeroCard` / `StyleCard` / `GenerationParamSection` / `GenerationProgressPanel` / `GenerationJobCard` / `QuotaMeter` / `ActiveJobBar` / `AiWorkGridSkeleton`);6 个改造项按 §4.2 完成
- [ ] A1A6 + S1 + G1 共 8 个界面单元,**每一个都具备 §3 表里为它列出的全部态**;`—` 的格子须在源码注释里写明「定型而非遗漏」及理由(沿 `iteration-3.5/05 §3.2` 的先例)
- [ ] **A3 的三条硬纪律**:(a) 屏幕上明写可以离开;(b) 文案**不出现「通知你」/「推送」**任何字样;(c) **不出现任何预计剩余时间/倒计时**(除「频率过快」的秒级倒计时,那是服务端给的确定值)
- [ ] `queued` 态**不使用确定型进度条**`progress==0``running` 态同
- [ ] 僵死态(`running` 超阈值)**不呈现为错误**,且取消钮在该态可见度提升
- [ ] 轮询:仅前台、退避阶梯按 §2.3、连续 3 次失败才提示、**不因抖动打断等待**;jobId 列表持久化但**任何 URL 都不落盘**(纪律 R2)
- [ ] 三道防重复提交闸齐备(`AbsorbPointer` + 幂等键 + A4「重新生成」换新键)
- [ ] A4 结果图按**真实比例**渲染(取 job 的 width/height),不走 `PostMediaGrid` 的 4:3 回落;钳制 `[9/16, 16/9]` 且超界用 `contain` 不裁切
- [ ] 「暂不发布」不叫「丢弃」;A5 的删除才是真删,且有二次确认
- [ ] 发布页:`initialDraftPostId` 生效且不误恢复旧草稿;**草稿图渲染为缩略图而非计数文字**;`ai_creation` 分类锁定为静态标签;**正文不预填任何机器文案**
- [ ] widthPx 三项(R1a/R1b/R1c)落地;`post_card.dart``post_detail_page.dart` 的单图比例逻辑**一致**(现在不一致)
- [ ] 色彩:§7.2 表内全部组合达 AA;**`brandGradient` 上无白字**`primary` 不作 2px 选中描边、不作进度值条;`success`/`warning` 不作图标或文字色;`Colors.white70` 于 scrim 零残留
- [ ] 次级信息文字全部显式 `inkSoft``muted` 仅出现在占位 / 禁用 / 纯装饰(DEBT-2 不新增欠账)
- [ ] 语义标注:`StyleCard``selected``GenerationProgressPanel``ActiveJobBar``liveRegion`Tab 角标可被读出;进度环 `ExcludeSemantics`
- [ ] reduce-motion`MediaQuery.maybeOf(...)?.disableAnimations`)下全部动效降级,**唯一例外是进度环旋转**,且该例外在源码注释里写明理由
- [ ] 触控目标全数 ≥44×44;不足处在源码显式记录妥协
- [ ] 200% 字体缩放下 `StyleCard` / `GenerationJobCard` 的底部字条不溢出(`maxLines:1` + ellipsis + 格高随字号)
- [ ] `create_page.dart` 的消亡清单(§4.5)逐项清除,`_ComposeEntryCard` 保留;`CreationStyle` / `creationStyles``models.dart` / `demo_data.dart` 移除
- [ ] 首页:**未新增任何 AI 入口卡**;G1 条插在页面第一位;`home_greeting_test.dart` 的「demo 占位仍在」反向用例仍绿
- [ ] 若顺手偿了 §8.1`segmentedButtonTheme` 落地,另 5 个使用点视觉回归无布局变化
---
## 附:本规范的自我限界
- **未取证项**:AI 提供方的真实生成耗时量级 / 是否上报进度 / 是否支持取消(取决于尚未产生的选型 ADR);风格目录的内容管理途径(R7);「保存到相册」依赖是否可批(D13)。这三处本规范给的是**可降级设计**而非确定形态。
- **本规范不引用测试数作为设计结论依据**。Flutter 基线以协作方实测为准:597 通过 + 2 skipped。`integration_test/` 下的真机/桌面脚本**在 `flutter test` 之外、无门禁会跑**,因此既有报告中的「桌面逐步实测截图」在本规范中仅作设计意图参考,**不作为「该 UI 链路已验证」的证据**;凡引用既有组件行为一律以源码行号为准。
- **本次未触碰任何生产代码、未改 `app_theme.dart`、未改 `mkdocs.yml`、未执行任何 git 提交。** 导航入档随波末统一处理。
---
**UI Designer** · 2026-09-14