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

115 KiB
Raw Blame History

05 · 第四迭代 AI 创作 UI 设计规范

作者:UI Designer 日期:2026-09-14 迭代:Iteration 4「M4 AI 创作」 性质:开工前设计规范;只定规格,不改代码(本次未触碰 patbond-flutter 任何文件、未改 mkdocs.yml、未 commit) 素材来源(全部已开源码/原文核实,行号见各节):

  • 品牌正典 AI宠物_iOS_UI设计稿.htmlADR-005),其第三画框正是「AI 创作」:317-356
  • 落地 token patbond-flutter/lib/core/theme/app_theme.dart
  • 前序规范 iteration-1/04iteration-2/05iteration-3/05iteration-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.dart563 行,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-66primary → 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/w800titleLarge 18/w800titleMedium 15/w700bodyMedium 14/h1.5bodySmall 12/h1.4默认色 mutedDEBT-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:134post_compose_page.dart:540-541home_page.dart:357-358 一致)。

正典色值与落地 token 逐一对齐AI宠物_iOS_UI设计稿.html:12-21):peach #FFE8D6=surfaceTintcoral #FF6F4C=primarycoral-dark #7A2E12=primaryDarkamber #FFB648=accentamber-dark #7A4B0A=accentDarkink #3E2A1F=inkmuted #9C8977=mutedborder #F0DCC8=bordersage #7FA88A=successsage-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/QUOTAopenapi.yaml:32-62 错误码表同;feature-checklist.md:194 把 429 记为 未做
AI 提供方决策 22 条 ADRdecisions.md:8-195)无 AI 供应方;development-plan.md:358 明写「AI 提供方…尚未确定
creation schema 实际建表 Flyway 止于 V5__community_baseline.sqldecisions.md:192「下一个版本号 V6 留给后续」
视频能力 openapi.yaml:3456-3457 kind enum 只有 imageADR-018 视频后置,decisions.md:159-161
WebSocket / SSE / 推送 无依赖、无端点

确定存在、可直接复用:两步上传闭环(openapi.yaml:1223:1256);media schema 可承载 AI 输入输出(development-plan.md:64);purpose 白名单是配置项而非 DB CHECKdecisions.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_creationCreatePostRequest.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-156coral→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-1733 列网格 gap 10、圆角 14、aspect-ratio 1/1.1peach 底、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 —— 零先例。 BadgeMaterial)全 lib 零使用(唯一 Badge 相关命中是 pet_avatar.dartshowEditBadge/_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:612input_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.yamlgenerate: 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

创作 Tab(底栏 index 1,✨ auto_awesomeAppBar 标题「创作中心」)
  └─ 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)→ 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(不用 mutedDEBT-2
  • Bottom sheet 沿用既有骨架(showDragHandle + useSafeArea + isScrollControlledavatar_upload_sheet.dart:45-48
  • 错误三层模型沿用:字段 errorText / 区块 InlineErrorBanner + 重试 / 瞬态 SnackBar
  • 触控目标 ≥44×44;不足时须在源码显式记录妥协(既有惯例 comment_tile.dart:114-115post_media_grid.dart:226
  • muted 仅限:输入占位符、禁用态、纯装饰图标

2. 页面规范

2.1 A1 创作首页(模型 / 风格目录)

替换 create_page.dart 的上半部。_ComposeEntryCard:394-419T3-17 落地的真实发布入口)保留不动——它是本页唯一已经真实的东西。

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×44inkSoft 图标。不放进 MainShellPage 的 AppBar actionsmain_shell_page.dart:251-262 是 5 个 Tab 共用的通知钮位,塞进去会在首页/档案/服务/我的四个 Tab 上错误露出)。

AiHeroCard 规格(新建):brandGradientapp_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不用 primaryprimary/canvas 仅 2.59:1 不达非文字 3:1primaryStrong/canvas 4.23:1;现 create_page.dart:237 正是 primary,§8.5 修订)+ 右上角 20 圆 primaryStrong 实底 + 白 check 图标 13(双通道,不靠颜色单通道传达选中)
按下 InkWell ripple 圆角随格
不可用 M4 不出现(目录端点只返回 enabled=true 的行——服务端已有 partial index WHERE enabledpatbond_postgresql.sql:601-603)。若拍板决定返回全量(D7),灰态形态为:整格 Opacity 0.5 + 移除 onTap + 底部字条改「暂不可用」+ 不渲染选中角标
预览图缺失 preview_asset_id 是 nullablepatbond_postgresql.sql:586)→ 无预览图时不留空白格surfaceTint 底 + 居中 auto_awesome 28 primary(纯装饰,与 EmptyStatemuted 图标同类,不受对比度约束)+ 底部字条照常

风格预览图从哪来creation.generation_styles.preview_asset_id → media.assetspatbond_postgresql.sql:586),即走既有两步上传 + 预签名 GET 的同一条路,客户端拿到的是时效性签名 URLRemoteImage 直接消费(缓存 key 已剥签名参数,common.dart:25-28)。不得持久化 URL(纪律 R2)。运营侧如何把图灌进去(后台?种子数据?)未取证 —— 缺一个「风格目录内容管理」的答案,属后端/运营范畴,记入 §10 R7。

模型(model)怎么呈现 —— 与风格分开generation_modelsgeneration_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-157SegmentedButton<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 + 其下「重试」TextButtonhero 保留可见但 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 高度不够且键盘弹起时披露区会被压没)。

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(新建):RowtitleMedium 标题 + 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 默认(参考图必填):参考图 readyMediaUploader.allReadyreadyCount == 1 已选风格非空 无在途提交。提示词参与 gating(选填)。
  • 若 D2 改为「参考图可选」gating 降级为 已选风格非空 && (参考图为空 || 参考图 ready) && (提示词非空 || 参考图非空) —— 即至少要有「一张图」或「一句话」,两者全空不能提交。同时 A1 hero 的 CTA 文案须从「+ 上传宠物照片」改为「开始创作」(正典文案绑定了「上传照片」这个前提,AI宠物_iOS_UI设计稿.html:330)。
  • 不满足时按钮走主题禁用态(ink 12% 底 + 38% 字,app_theme.dart:137-138),并在按钮上方给一行 12 inkSoft 的原因(「先选一张宠物照片」/「先选一个风格」)—— 只置灰不说原因是既有页面的通病,M4 不复制。

参考图上传:复用而非重写。 走既有 MediaUploadermedia_uploader.dart:127-143+ PostMediaEditGridpost_media_grid.dart:128+ UploadProgressOverlayupload_progress_overlay.dart:17),参数 maxImages: 1, maxConcurrentUploads: 1, purpose: MediaPurpose.aiInput。六态(queued/compressing/uploading/confirming/ready/failedmedia_uploader.dart:17-24)的视觉已在 UploadProgressOverlay 里定型,一格都不用重画

新增 MediaPurpose 枚举值:现有三值 postImage/userAvatar/petAvatarcommunity_models.dart:67-75)→ 加 aiInput('ai_input')aiOutput('ai_output')零 Flyway 迁移purpose 无 DB CHECK,白名单是配置项 MediaProperties.allowedPurposesADR-022 决策 5decisions.md:192);但引用侧会校验用途相符,给错是 404/40405(ErrorCode.java:28)。

不用 AvatarUploadSheetavatar_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:契约硬上限 10000patbond_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。

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. 主形态是「阶段文字 + 不确定进度环」,不是百分比。 因为 queuedprogress 恒 0(硬约束),runningprogress 无下限约束(提供方可能全程不上报)。若把百分比当主形态,最常见的用户观感就是「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
  • queuedrunning 都允许取消(设计稿的 cancelled 态对 output/error 无约束,两处都能进)

状态矩阵

服务端 status 呈现
加载(首次查询前) 进度环不确定 + 「正在提交…」;不显示已用时(还没有起点)
排队中 queued 不确定环 + 「已排队」+ 已用时 +(有 queuePosition 则)「前面还有 N 个」
生成中(无进度) runningprogress==0 不确定环 + 「正在生成…」+ 已用时
生成中(有进度) runningprogress>0 确定环 + 环心百分比 + 「正在生成…」+ 已用时
僵死 / 超时 running 且已用时 > 阈值 见上第六态(accent 环 + 提升的取消钮)。非错误态
成功 succeeded 环补满 100% → 200 ms 停顿 → 自动 pushReplacement 进 A4(不让用户再点一次「查看结果」;结果就是他等的东西)
失败 failed 见下失败态设计
已取消 cancelled EmptyStateIllustration 终态 + 「再试一次」
轮询请求本身失败 不打断等待:静默重试下一个 tick;连续 3 次失败才在页面底部加一行 12 inkSoft「网络不太稳,正在重连…」。任务在服务端照常跑,把网络抖动升级成错误页是过度反应
空 / 无权限 不存在(本页必然有一个 jobId 才能到达;jobId 查不到 → 404 走失败态)

失败态设计failederror_code 非空是硬约束):

┌──────────────────────────────────────────┐
│            ⊘  56  errorDark              │  非 EmptyStateIllustration
│         这次没有生成成功        titleLarge │  (那个是"空"语义,不是"失败")
│   参考图里没有识别到宠物,换一张试试  bodyMedium inkSoft
│                                          │
│  ┌───────────────┐ ┌─────────────────┐  │
│  │  换个参考图     │ │  用同样设置重试   │  │
│  └───────────────┘ └─────────────────┘  │
│      OutlinedButton      FilledButton    │
│  这次失败不消耗额度  ← 若 D11 定为不扣   │
└──────────────────────────────────────────┘
  • 文案按 error_code 分层(§5 文案表),兜底文案不能是「未知错误」:给「这次生成没成功,换个参考图或稍后再试」+ 折叠区可展开看 error_message(给愿意看的人,varchar(1000)
  • attempt_count / max_attemptspatbond_postgresql.sql:628-629):服务端已自动重试过 N 次。UI 不暴露内部重试次数(用户不关心 worker 重试了 3 遍),但若 attempt_count == max_attempts 则「用同样设置重试」按钮改为次要态并在下方加一行「同样的设置已经试过几次了,建议换张照片」
  • 失败是否退额度是 D11 的一部分,必须与文案一致

2.4 A4 结果预览页

由 A3 成功后 pushReplacement 进入(返回键回 A1,不回 A3——那是个已终结的等待页)。

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_pxpatbond_postgresql.sql:616-617),那是客户端自己刚刚提交的参数,一定知道。所以 A4 能画出完整正确的构图。债在发布之后才显现Feed 读的是 media.assets 的尺寸,恒 null)—— 这个前后对照正是 §8.3 判定「M4 由隐性升为显性」的核心论据。

  • 保护性钳制:即使比例已知,也钳到 [9/16, 16/9]0.5625–1.778)防异常值撑爆页面;超出时 BoxFit.contain + canvas 底衬(不裁切——用户刚花额度生成的图,宁可留白不可裁)。

三个动作的语义与文案,逐个都有讲究

动作 形态 设计判断
发布到社区 主 CTAFilledButton 全宽 这是产品希望发生的事,给最高视觉权重
重新生成 次要,OutlinedButton 必须在按钮上写明「将消耗 1 次额度」。这是会花钱的操作,把代价藏在点击之后是暗黑模式。同参数直接重提(不回 A2),但换新的 idempotency_key(否则命中 UNIQUE(user_id, idempotency_key) 会返回同一个旧 job,用户看到「什么都没发生」)
暂不发布 次要,OutlinedButton 不叫「丢弃」/「删除」。 job 已在服务端存在、图也在 media.assets 里,点这个只是「现在不发帖」。叫「丢弃」会让用户以为作品被销毁,从而不敢点。配一行说明「作品已存在「我的 AI 作品」里,之后也能发布」。真要删是 A5 的删除动作

放大查看 → A6。 复用既有 _MediaGalleryPagepost_detail_page.dart:880-985):黑底 + PageView + InteractiveViewer(maxScale:4) + 双击 2.5× 缩放(:904-914+ 右上「n/N」ink 胶囊 + 关闭钮 tooltip:'关闭':953-957)。它现在是 post_detail_page.dart 的私有类,需提为共享 MediaGalleryPagelib/core/widgets/media_gallery_page.dart)。单图时不渲染页码胶囊(既有逻辑 :959 已判 urls.length > 1),零改造。

  • 已知遗留(不在 M4 修):下滑关闭手势与 InteractiveViewer 平移冲突,post_detail_page.dart:878-879 已登记「留待手势方案升级(photo_view 复评条件:体验不达标)」。M4 沿用现状,只提取不改行为。

状态矩阵

呈现
加载(图片下载中) RemoteImage 自带 loadingsurfaceTint 底 + 18 转圈(common.dart:32-44)。外层不再叠一层骨架(会双重转圈)
图片下载失败 RemoteImage 自带兜底:surfaceTint + Icons.pets 34 mutedcommon.dart:45-56)+ 其下一行「图片加载失败,下拉重试」+ 三个动作钮照常可用(图没显示不等于作品不存在)
成功 如上线框
进行中 点「发布到社区」后:钮内转圈 + 另两钮禁用(防并发建草稿);点「重新生成」后:pushReplacement 回 A3
失败(建草稿失败) InlineErrorBanner 在图下方 + 重试;停在本页不跳转(跳到发布页会得到一个没有图的空草稿)
配额耗尽 只影响「重新生成」:该钮转禁用 + 下方文案改「今日额度已用完,明天再来」;「发布到社区」不受影响(发帖不消耗生成额度)
空 / 无权限 不存在(有 succeeded job 才能到达本页)

2.5 A5 我的 AI 作品与任务列表

一个列表承载全部五种 status(不做「作品」与「任务」两个分开的列表——用户心智里它们是同一串东西的不同阶段)。

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 errorDark6.50:1+ 底部 errorDark 实底白字「失败」 A3 的失败态
cancelled canvas 底 + 居中 cancel_outlined 24 muted(禁用语义,muted 合法用途)+ 底部字条「已取消」 A3 的取消终态
  • 长按 → 操作 sheet:「保存到相册 / 删除」(删除需二次确认;这里才是真的删,与 A4 的「暂不发布」区分)
  • 「保存到相册」需新依赖(gal / image_gallery_saver 之类)—— 未取证该依赖是否可接受,记入 D13

这里需要月份筛选吗 —— 不需要。 若产品要「按月看作品」,才会撞上 §8.2 的月份网格缺失。本规范建议 M4 不做时间筛选(作品量级在 M4 不足以需要筛选,游标分页够用),从而不触发那笔债

状态矩阵

呈现
首载 AiWorkGridSkeleton9 格 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 进发布页。

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 沿用既有 _canPublishpost_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 看过完整的图,进发布页却只看到一行字,会以为图丢了。修法:把 _draftMediaPostMediaGrid(展示态,urls_draftMedia[i].url)渲染出来。零新建组件。
    • 注意:PostMediaGrid 单图会回落 4:3post_media_grid.dart:326-327),此处可接受(发布页是编辑上下文,不是最终呈现),但若一并按 §8.3 修好 PostMediaGrid 读比例,这里也顺带正确。
  3. PostEntryPoint 枚举加值:现有 createTab/feed/topicDetail/petDetailpost_analytics.dart:19-28),加 aiResult(入口归因,埋点侧需同步 → 交由埋点规格)。

2.7 G1 全局「进行中」指示位 + S1 配额说明

要不要一个持久的任务入口 —— 要,但要最轻的那种。 理由:无推送能力(§2.3),用户离开 A3 后唯一能知道任务状态的途径就是自己回来看。没有全局提示位,「离开」这件事就等于「失联」。

G1 由两个部件组成(都是客户端首例,§0.5):

部件 形态 位置
ActiveJobBar 进行中条 高 52 的 surfaceTint 底圆角 sm 12 横条:左 20 不确定转圈 primaryStrong → 10 → 文字(primaryDark7.98:1)→ Spacerchevron_right 20 primaryDark。整条可点 → A3 页面第一位(首页 ListView children 首位、创作 Tab 首位)
Tab 角标 Material Badge内置组件,客户端首次使用)包住底栏创作 Tab 的 auto_awesome_outlined 图标。小圆点无数字BadgesmallSize 形态),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 successInk7.90:1不用 success,白/淡底上仅 2.292.67:1 不达非文字 3:1,§7.2 D10);底色转 successSurface,字转 successInk6.79:1
  • 「已完成」态在用户点进去看过之后消失(不是超时消失)。角标同步。这是唯一让用户不会漏掉结果的规则。
  • 完成时若 App 在前台且轮询到 succeeded:额外弹一条 SnackBar「作品生成好了」+ action「查看」。不弹对话框(用户可能正在打字/浏览,抢焦点是敌意行为)。

S1 配额说明 sheet429 的出口):

┌ 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 / 轨道 surfaceTint3.80:1 达非文字 3:1不能用 primary 值条 —— primary/surfaceTint 仅 2.33:1+ 右侧 3/5 12/w600 inkSoft。耗尽态值条转 errorDark6.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(恰为最小触控目标);全宽按钮沿用

EmptyStatecommon.dart:134-156M4 不用 —— 已核实它无 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 _MediaGalleryPageMediaGalleryPage 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.dart563 行)

现状 行号 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-297demo_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 ?? falsemaybeOf + 兜底,沿 like_button.dart:104-105 的稳妥写法,而不是 feed_skeleton.dart:40MediaQuery.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_jobsUNIQUE(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:253create_page.dart:463post_detail_page.dart:954
  • InkWell / GestureDetectorSemantics(label:, button:),且 button: 随可用性变化(like_button.dart:159-161button: widget.onPressed != null 是最规范的写法)
  • 触控目标 ≥44×44;不足时在源码显式记录妥协(comment_tile.dart:114-115post_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 §5WCAG 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 底文字一律 primaryDark8.88
primaryDark / surfaceTintG1 条文字、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 修订为 primaryStrong4.23
success 图标 / surface(现 create_page.dart:320:553 2.67 不达非文字 3:1 → D10 修订为 successInk7.90
success / successSurface 2.29 同上禁用
successInk / surface / successSurfaceG1 完成态图标与文字) 7.90 / 6.79 达标
warning / surface 2.15 warning 不可作图标或文字色(任何底上都不行:canvas 2.02)。僵死态用 accentDark7.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 / surfacehint、禁用、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,轨道恒 surfaceTint3.80)。primary 值条禁用
4 语义色的「图标可用性」 success2.67)与 warning2.15都不达非文字 3:1,二者只能作淡底,图标/文字必须用 successInk7.90/ accentDark(7.40)。这条应作为全 app 规则登记(不止 M4)

8. 设计债处置意见

8.1 SegmentedButton 粉底(M2 遗留)

核实结论(两路独立复核):

  • app_theme.dart 没有 segmentedButtonThemebuildAppTheme() 只定制 8 个子主题::105/124/133/146/173/179/180);全 lib/ grep segmentedButtonTheme|SegmentedButtonThemeData 零命中
  • Flutter SDK(本机 3.44.6)默认:选中容器取 colorScheme.secondaryContainer、前景取 onSecondaryContainerflutter/packages/flutter/lib/src/material/segmented_button.dart:1214:1224-1232
  • app_theme.dart:88-92fromSeed 只 override 了 surface/error/onError没 override secondaryContainer
  • 精确色值(本机 material_color_utilities 0.13.0 + SchemeTonalSpot(#FF6F4C) 实算):secondaryContainer = #FFDAD2onSecondaryContainer = #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 #85736Fborder #F0DCC8
  • 收益面:一处主题修好 6 个使用点(全部已核实无局部 style: 覆盖)—— home_page.dart:385create_page.dart:138services_page.dart:102pet_form_page.dart:426pet_form_page.dart:457vaccination_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 integerCHECK 明确允许 NULL:241-245
服务端 main 代码对 media.assets 的三条写 SQL 全都不含这两列MediaAssetRepository.java:35-41insertUploading)、:82-86markReady)、:94-97markFailed)。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-521null 时回落 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:3post_media_grid.dart:326-327
数据通路 FeedCard.coverImagePostMediaItem?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_pxpatbond_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.coverImagePostMediaItem 客户端 S
R1c 详情页钳制下界从 1/1.330.752)放宽到 9/160.5625post_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 入口卡片。

  • 底栏创作 Tabmain_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.dartM4 替换时一并纠正)

这三处都在 M4 的替换范围内,不是额外工作量,但值得写明避免照抄:

# 位置 问题 修法
1 create_page.dart:237 选中风格卡描边用 AppColors.primary,于 canvas2.59:1,不达非文字 3:1 primaryStrong4.23+ 加勾选角标(双通道)
2 create_page.dart:320:552-554 Icons.check_circle / 阶段勾选用 AppColors.success,于白卡 2.67:1,不达非文字 3:1 successInk7.90)。这条应升为全 app 规则successwarning 只能作淡底,不能作图标/文字(§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 达标,但主按钮主题本是 primaryStrongapp_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:612input_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 runningprogress 是否保证 >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 只有 imageopenapi.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 的生成尺寸,无需图片解码);配套客户端 R1bpost_card.dart:70-73 单图读比例)+ R1c(详情页钳制下界放宽到 9/16) 否则 M4 的招牌功能产出的作品在社区里一律显示为被裁坏的 4:3,而用户刚在 A4 看过完整构图 —— 同一次会话内的直接对照(§8.3 全文) S(三处各 S
R2 目录端点给出每个模型的支持比例白名单 + 基准边长 A2 的画面比例是枚举 chip,客户端要按「基准边长 × 比例」换算像素。没有白名单,客户端会算出提供方不支持的尺寸,提交必被拒(D5) S
R3 任务状态响应带 queuePositionqueued 时) 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 挡着
R6 POST /posts 接受 generationJobIdPost / 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 取消端点须在 queuedrunning 两态都可用 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==0running 态同
  • 僵死态(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.dartpost_detail_page.dart 的单图比例逻辑一致(现在不一致)
  • 色彩:§7.2 表内全部组合达 AAbrandGradient 上无白字primary 不作 2px 选中描边、不作进度值条;success/warning 不作图标或文字色;Colors.white70 于 scrim 零残留
  • 次级信息文字全部显式 inkSoftmuted 仅出现在占位 / 禁用 / 纯装饰(DEBT-2 不新增欠账)
  • 语义标注:StyleCardselectedGenerationProgressPanelActiveJobBarliveRegionTab 角标可被读出;进度环 ExcludeSemantics
  • reduce-motionMediaQuery.maybeOf(...)?.disableAnimations)下全部动效降级,唯一例外是进度环旋转,且该例外在源码注释里写明理由
  • 触控目标全数 ≥44×44;不足处在源码显式记录妥协
  • 200% 字体缩放下 StyleCard / GenerationJobCard 的底部字条不溢出(maxLines:1 + ellipsis + 格高随字号)
  • create_page.dart 的消亡清单(§4.5)逐项清除,_ComposeEntryCard 保留;CreationStyle / creationStylesmodels.dart / demo_data.dart 移除
  • 首页:未新增任何 AI 入口卡G1 条插在页面第一位;home_greeting_test.dart 的「demo 占位仍在」反向用例仍绿
  • 若顺手偿了 §8.1segmentedButtonTheme 落地,另 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