# 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 sheet(S1 配额说明)+ 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` | :162,13/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` 12;scrim 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_awesome,AppBar 标题「创作中心」) └─ A1 创作首页(hero + 配额行 + 风格目录 3 列网格 + 右上历史入口) ├─ A2 生成参数页(参考图 + 提示词 + 两级渐进披露) │ └─ A3 任务等待页(提交后自动进;可离开) │ ├─ A4 结果预览页(单图原比例 + 重新生成 / 暂不发布 / 发布到社区) │ │ ├─ A6 全屏结果查看(复用提取) │ │ └─ → PostComposePage(预填草稿,§2.6) │ └─ S1 配额说明 sheet(429 出口) └─ A5 我的 AI 作品与任务列表(网格 + 状态角标;第二入口在「我的」页菜单) G1 全局「进行中」指示位(跨 Tab;首页与创作 Tab 顶部条 + 底栏创作 Tab 角标) ``` **入口三处**(均已有正典依据,非新增提案):底栏创作 Tab(`main_shell_page.dart:277-281`)→ A1;A1 右上 `history` 钮(正典 `🕘`,:325)→ A5;「我的」页菜单「我的 AI 作品」(正典 :497)→ A5。 **通用排版 token**(延续前三份规范,不新造): - 页面内边距 `EdgeInsets.fromLTRB(16, 12, 16, 28)`;间距刻度 4 / 8 / 12 / 16 / 24 / 32;卡间距 16,分区间距 24 - 圆角:卡片 `xl` 24(Card 主题默认)、输入框 `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-419,T3-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`(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:← 返回 / 「正在生成」/ 右侧「取消」TextButton(inkSoft) ┌──────────────────────────────────────────┐ │ │ │ ◜◝ │ 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) - 退避阶梯:0–30 s 每 3 s;30 s–2 min 每 5 s;2 min–10 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: , │ media:[{ assetId: , 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.29–2.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` | :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.08,accent 端 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` 字/w700(7.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-365,demo)→ 问候卡(:367-375,问候名真实、右侧大图与建议仍 demo)→ 搜索框(:377-384,demo)→ `SegmentedButton`(:385-401,真)→ `_StoryRow`(:404,圈子是 demo,环内「发布」是真入口)→ `_PromoCard`(:406,demo)→ 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` 进 A3;A3 **明确允许离开**;进度用「阶段文字 + 不确定环」为主,`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 完成 - [ ] A1–A6 + 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